snippet_cli 0.3.6 → 0.5.2
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 +4 -4
- data/README.md +29 -15
- data/exe/snippet_cli +45 -11
- data/lib/snippet_cli/Espanso_Merged_Matchfile_Schema.json +889 -0
- data/lib/snippet_cli/banner.rb +16 -0
- data/lib/snippet_cli/commands/check.rb +49 -0
- data/lib/snippet_cli/commands/conflict.rb +75 -0
- data/lib/snippet_cli/commands/new.rb +16 -102
- data/lib/snippet_cli/commands/vars.rb +58 -0
- data/lib/snippet_cli/commands/version.rb +17 -0
- data/lib/snippet_cli/conflict_detector.rb +71 -0
- data/lib/snippet_cli/cursor_helper.rb +20 -0
- data/lib/snippet_cli/espanso_config.rb +26 -0
- data/lib/snippet_cli/file_helper.rb +16 -0
- data/lib/snippet_cli/file_validator.rb +33 -0
- data/lib/snippet_cli/file_writer.rb +12 -0
- data/lib/snippet_cli/form_field_parser.rb +13 -0
- data/lib/snippet_cli/global_vars_formatter.rb +63 -0
- data/lib/snippet_cli/global_vars_writer.rb +29 -0
- data/lib/snippet_cli/gum_theme.rb +39 -0
- data/lib/snippet_cli/hash_utils.rb +21 -0
- data/lib/snippet_cli/match_file_writer.rb +26 -0
- data/lib/snippet_cli/match_validator.rb +33 -0
- data/lib/snippet_cli/new_workflow.rb +98 -0
- data/lib/snippet_cli/replacement_text_collector.rb +55 -0
- data/lib/snippet_cli/replacement_validator.rb +35 -0
- data/lib/snippet_cli/replacement_wizard.rb +100 -0
- data/lib/snippet_cli/schema_validator.rb +27 -0
- data/lib/snippet_cli/snippet_builder.rb +133 -0
- data/lib/snippet_cli/string_helper.rb +11 -0
- data/lib/snippet_cli/table_formatter.rb +37 -0
- data/lib/snippet_cli/trigger_resolver.rb +67 -0
- data/lib/snippet_cli/ui.rb +90 -0
- data/lib/snippet_cli/var_builder/form_fields.rb +54 -0
- data/lib/snippet_cli/var_builder/name_collector.rb +69 -0
- data/lib/snippet_cli/var_builder/param_schema.rb +71 -0
- data/lib/snippet_cli/var_builder/params.rb +127 -0
- data/lib/snippet_cli/var_builder.rb +125 -0
- data/lib/snippet_cli/var_summary_renderer.rb +48 -0
- data/lib/snippet_cli/var_usage_checker.rb +48 -0
- data/lib/snippet_cli/var_yaml_renderer.rb +20 -0
- data/lib/snippet_cli/vars_block_renderer.rb +15 -0
- data/lib/snippet_cli/version.rb +3 -1
- data/lib/snippet_cli/wizard_context.rb +13 -0
- data/lib/snippet_cli/wizard_helpers/error_handler.rb +20 -0
- data/lib/snippet_cli/wizard_helpers/match_file_selector.rb +32 -0
- data/lib/snippet_cli/wizard_helpers/prompt_helpers.rb +57 -0
- data/lib/snippet_cli/wizard_helpers/validation_loop.rb +33 -0
- data/lib/snippet_cli/wizard_helpers.rb +8 -0
- data/lib/snippet_cli/yaml_line_resolver.rb +46 -0
- data/lib/snippet_cli/yaml_loader.rb +19 -0
- data/lib/snippet_cli/yaml_param_renderer.rb +33 -0
- data/lib/snippet_cli/yaml_scalar.rb +48 -0
- data/lib/snippet_cli.rb +44 -1
- metadata +134 -101
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.travis.yml +0 -9
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -17
- data/Rakefile +0 -10
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/lib/Setup.rb +0 -76
- data/lib/banner.rb +0 -16
- data/lib/snippet_cli/cli.rb +0 -62
- data/lib/snippet_cli/command.rb +0 -121
- data/lib/snippet_cli/commands/info.md +0 -20
- data/lib/snippet_cli/commands/info.rb +0 -36
- data/lib/snippet_cli/commands/setup.rb +0 -108
- data/lib/snippet_cli/templates/.gitkeep +0 -1
- data/lib/snippet_cli/templates/config/.gitkeep +0 -1
- data/lib/snippet_cli/templates/create/.gitkeep +0 -1
- data/lib/snippet_cli/templates/info/.gitkeep +0 -1
- data/lib/snippet_cli/templates/setup/.gitkeep +0 -1
- data/lib/snippet_generator.rb +0 -85
- data/snippet_cli-0.1.0.gem +0 -0
- data/snippet_cli-0.1.1.gem +0 -0
- data/snippet_cli-0.1.2.gem +0 -0
- data/snippet_cli-0.1.3.gem +0 -0
- data/snippet_cli-0.1.4.gem +0 -0
- data/snippet_cli-0.1.5.gem +0 -0
- data/snippet_cli-0.1.6.gem +0 -0
- data/snippet_cli-0.1.7.gem +0 -0
- data/snippet_cli-0.1.8.gem +0 -0
- data/snippet_cli-0.1.9.gem +0 -0
- data/snippet_cli-0.2.0.gem +0 -0
- data/snippet_cli-0.2.1.gem +0 -0
- data/snippet_cli-0.2.2.gem +0 -0
- data/snippet_cli-0.2.3.gem +0 -0
- data/snippet_cli-0.2.4.gem +0 -0
- data/snippet_cli-0.2.6.gem +0 -0
- data/snippet_cli-0.2.7.gem +0 -0
- data/snippet_cli-0.2.8.gem +0 -0
- data/snippet_cli.gemspec +0 -37
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gum'
|
|
4
|
+
require_relative 'cursor_helper'
|
|
5
|
+
|
|
6
|
+
module SnippetCli
|
|
7
|
+
module UI
|
|
8
|
+
BASE_FLAGS = ['--border=rounded', '--padding=0 4'].freeze
|
|
9
|
+
PROMPT_STYLE = { padding: '0 1', margin: '0' }.freeze
|
|
10
|
+
|
|
11
|
+
STYLE_FLAGS = {
|
|
12
|
+
info: [],
|
|
13
|
+
hint: ['--border-foreground=220'],
|
|
14
|
+
success: ['--border-foreground=46', '--bold'],
|
|
15
|
+
warning: ['--border-foreground=220', '--foreground=220', '--bold'],
|
|
16
|
+
error: ['--border-foreground=196', '--foreground=196', '--bold'],
|
|
17
|
+
preview: []
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.note(text)
|
|
21
|
+
puts "\e[38;5;231m#{text}\e[0m"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.info(text) = gum_style(text, *STYLE_FLAGS[:info])
|
|
25
|
+
def self.hint(text) = gum_style(text, *STYLE_FLAGS[:hint])
|
|
26
|
+
def self.success(text) = gum_style(text, *STYLE_FLAGS[:success])
|
|
27
|
+
def self.warning(text) = gum_style(text, *STYLE_FLAGS[:warning])
|
|
28
|
+
def self.error(text) = gum_style(text, *STYLE_FLAGS[:error])
|
|
29
|
+
def self.preview(text) = gum_style(text, *STYLE_FLAGS[:preview])
|
|
30
|
+
|
|
31
|
+
# Renders a warning and returns a lambda that erases it via line-count tracking.
|
|
32
|
+
# The warning is always rendered; the clear lambda is a no-op when not a TTY.
|
|
33
|
+
def self.transient_note(text)
|
|
34
|
+
puts "\e[38;5;231m #{text}\e[0m"
|
|
35
|
+
puts
|
|
36
|
+
erase_lambda(2)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.transient_warning(text)
|
|
40
|
+
warning(text)
|
|
41
|
+
erase_lambda(text.lines.count + 2)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.transient_error(text)
|
|
45
|
+
error(text)
|
|
46
|
+
erase_lambda(text.lines.count + 2)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Renders an info box and returns a lambda that erases it via line-count tracking.
|
|
50
|
+
# The info box is always rendered; the clear lambda is a no-op when not a TTY.
|
|
51
|
+
def self.transient_info(text)
|
|
52
|
+
info(text)
|
|
53
|
+
erase_lambda(text.lines.count + 2)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.erase_lambda(line_count)
|
|
57
|
+
CursorHelper.build_erase_lambda(line_count)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :erase_lambda
|
|
60
|
+
|
|
61
|
+
def self.format_code(text, language: 'yaml')
|
|
62
|
+
Gum::Command.run_display_only('format', '--type=code', "--language=#{language}", input: text)
|
|
63
|
+
puts
|
|
64
|
+
rescue Gum::Error
|
|
65
|
+
puts text
|
|
66
|
+
puts
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Delivers YAML output: pipes to stdout if piped, or displays with a label if interactive.
|
|
70
|
+
# context must respond to #pipe_output (nil = interactive mode).
|
|
71
|
+
def self.deliver(yaml, label:, context: nil)
|
|
72
|
+
pipe = context&.pipe_output
|
|
73
|
+
if pipe
|
|
74
|
+
pipe.print yaml
|
|
75
|
+
else
|
|
76
|
+
info("#{label} YAML below.")
|
|
77
|
+
format_code(yaml)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Pass text via stdin instead of as a positional CLI argument.
|
|
82
|
+
# Gum's arg parser interprets leading `-` characters (e.g. YAML list
|
|
83
|
+
# markers like `- triggers:`) as unknown flags when passed positionally.
|
|
84
|
+
def self.gum_style(text, *extra_flags)
|
|
85
|
+
result = Gum::Command.run_non_interactive('style', *BASE_FLAGS, *extra_flags, input: text)
|
|
86
|
+
puts result
|
|
87
|
+
end
|
|
88
|
+
private_class_method :gum_style
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gum'
|
|
4
|
+
require_relative '../ui'
|
|
5
|
+
require_relative '../form_field_parser'
|
|
6
|
+
|
|
7
|
+
module SnippetCli
|
|
8
|
+
module VarBuilder
|
|
9
|
+
# Collects field-level configuration for form variable layouts.
|
|
10
|
+
module FormFields
|
|
11
|
+
FIELD_TYPES = [
|
|
12
|
+
'Single-line text box',
|
|
13
|
+
'Multi-line text box',
|
|
14
|
+
'Choice box',
|
|
15
|
+
'List box'
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.collect(builder, layout)
|
|
19
|
+
field_names = FormFieldParser.extract(layout)
|
|
20
|
+
fields = {}
|
|
21
|
+
field_names.each do |name|
|
|
22
|
+
type = builder.prompt!(
|
|
23
|
+
Gum.filter(*FIELD_TYPES, limit: 1, header: "[[#{name}]] field type")
|
|
24
|
+
)
|
|
25
|
+
config = field_config(builder, name, type)
|
|
26
|
+
fields[name.to_sym] = config if config
|
|
27
|
+
end
|
|
28
|
+
fields
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.field_config(builder, name, type)
|
|
32
|
+
case type
|
|
33
|
+
when 'Multi-line text box'
|
|
34
|
+
{ multiline: true }
|
|
35
|
+
when 'Choice box'
|
|
36
|
+
{ type: :choice, values: collect_values(builder, name) }
|
|
37
|
+
when 'List box'
|
|
38
|
+
{ type: :list, values: collect_values(builder, name) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
private_class_method :field_config
|
|
42
|
+
|
|
43
|
+
def self.collect_values(builder, field_name)
|
|
44
|
+
loop do
|
|
45
|
+
values = Params.collect_list(builder, "#{field_name} value")
|
|
46
|
+
return values if values.length >= 2
|
|
47
|
+
|
|
48
|
+
UI.warning("Provide at least 2 values for [[#{field_name}]].")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :collect_values
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../ui'
|
|
4
|
+
require_relative '../wizard_helpers/prompt_helpers'
|
|
5
|
+
require_relative '../wizard_helpers/validation_loop'
|
|
6
|
+
|
|
7
|
+
module SnippetCli
|
|
8
|
+
module VarBuilder
|
|
9
|
+
# Handles the interactive name-collection loop: prompts for a variable
|
|
10
|
+
# name, validates it (non-empty, no prohibited chars, no duplicates), and
|
|
11
|
+
# returns the accepted name or nil when a duplicate is detected.
|
|
12
|
+
class NameCollector
|
|
13
|
+
include WizardHelpers::PromptHelpers
|
|
14
|
+
include WizardHelpers::ValidationLoop
|
|
15
|
+
|
|
16
|
+
def initialize(existing)
|
|
17
|
+
@existing = existing
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the validated name string, or nil for a duplicate.
|
|
21
|
+
# Raises WizardInterrupted on Ctrl+C / nil input.
|
|
22
|
+
FIRST_VAR_HEADER = "One replacement may use multiple variables.\nEnter names one at a time.\n"
|
|
23
|
+
|
|
24
|
+
def collect
|
|
25
|
+
first = @existing.empty?
|
|
26
|
+
name = prompt_until_valid do
|
|
27
|
+
n, first = prompt_name(first)
|
|
28
|
+
[n, name_validation_error(n)]
|
|
29
|
+
end
|
|
30
|
+
return nil if duplicate?(name)
|
|
31
|
+
|
|
32
|
+
name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def prompt_name(first)
|
|
38
|
+
opts = { placeholder: 'Your variable name', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE }
|
|
39
|
+
opts[:header] = FIRST_VAR_HEADER if first
|
|
40
|
+
[prompt!(Gum.input(**opts)), false]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def name_validation_error(name)
|
|
44
|
+
return 'Variable name cannot be empty. Please enter a name.' if name.strip.empty?
|
|
45
|
+
return prohibited_char_message(name) if prohibited_char?(name)
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def duplicate?(name)
|
|
51
|
+
return false unless @existing.any? { |v| v[:name] == name }
|
|
52
|
+
|
|
53
|
+
warn "Variable '#{name}' already defined — skipping"
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def prohibited_char?(name)
|
|
58
|
+
PROHIBITED_CHARS.any? { |char| name.include?(char) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prohibited_char_message(name)
|
|
62
|
+
prohibited = PROHIBITED_CHARS.map { |c| "'#{c}'" }.join(', ')
|
|
63
|
+
"Variable name '#{name}' contains a prohibited character " \
|
|
64
|
+
"(#{prohibited}) — use only letters, digits, and underscores"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_constant :NameCollector
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnippetCli
|
|
4
|
+
module VarBuilder
|
|
5
|
+
# Pure data layer: describes what params are valid for each var type.
|
|
6
|
+
# No Gum/UI calls — independently testable.
|
|
7
|
+
module ParamSchema
|
|
8
|
+
SCHEMAS = {
|
|
9
|
+
'echo' => {
|
|
10
|
+
required: [:echo],
|
|
11
|
+
optional: [],
|
|
12
|
+
field_types: { echo: :string }
|
|
13
|
+
},
|
|
14
|
+
'random' => {
|
|
15
|
+
required: [:choices],
|
|
16
|
+
optional: [],
|
|
17
|
+
field_types: { choices: :string_array }
|
|
18
|
+
},
|
|
19
|
+
'choice' => {
|
|
20
|
+
required: [:values],
|
|
21
|
+
optional: [],
|
|
22
|
+
field_types: { values: :string_array }
|
|
23
|
+
},
|
|
24
|
+
'date' => {
|
|
25
|
+
required: [:format],
|
|
26
|
+
optional: %i[offset locale tz],
|
|
27
|
+
field_types: { format: :string, offset: :integer, locale: :string, tz: :string }
|
|
28
|
+
},
|
|
29
|
+
'shell' => {
|
|
30
|
+
required: %i[cmd shell],
|
|
31
|
+
optional: %i[debug trim],
|
|
32
|
+
field_types: { cmd: :string, shell: :string, debug: :boolean, trim: :boolean }
|
|
33
|
+
},
|
|
34
|
+
'script' => {
|
|
35
|
+
required: [:args],
|
|
36
|
+
optional: [:trim],
|
|
37
|
+
field_types: { args: :string_array, trim: :boolean }
|
|
38
|
+
},
|
|
39
|
+
'form' => {
|
|
40
|
+
required: [:layout],
|
|
41
|
+
optional: [:fields],
|
|
42
|
+
field_types: { layout: :string, fields: :any }
|
|
43
|
+
},
|
|
44
|
+
'clipboard' => {
|
|
45
|
+
required: [],
|
|
46
|
+
optional: [],
|
|
47
|
+
field_types: {}
|
|
48
|
+
}
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
def self.known_type?(type)
|
|
52
|
+
SCHEMAS.key?(type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.schema_for(type)
|
|
56
|
+
SCHEMAS[type]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns true if params hash contains all required fields and no unknown fields.
|
|
60
|
+
# Pure method — no UI calls.
|
|
61
|
+
def self.valid_params?(type, params)
|
|
62
|
+
schema = SCHEMAS[type]
|
|
63
|
+
return false unless schema
|
|
64
|
+
|
|
65
|
+
allowed = schema[:required] + schema[:optional]
|
|
66
|
+
schema[:required].all? { |f| params.key?(f) } &&
|
|
67
|
+
params.keys.all? { |k| allowed.include?(k) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gum'
|
|
4
|
+
require_relative 'param_schema'
|
|
5
|
+
require_relative 'form_fields'
|
|
6
|
+
|
|
7
|
+
module SnippetCli
|
|
8
|
+
module VarBuilder
|
|
9
|
+
# Interactive collection of Espanso `params` hashes per variable type.
|
|
10
|
+
module Params
|
|
11
|
+
COLLECTORS = {
|
|
12
|
+
'echo' => lambda { |b|
|
|
13
|
+
{ echo: b.prompt!(Gum.input(placeholder: 'echo value', prompt_style: UI::PROMPT_STYLE,
|
|
14
|
+
header_style: UI::PROMPT_STYLE)) }
|
|
15
|
+
},
|
|
16
|
+
'random' => lambda { |b|
|
|
17
|
+
raw = b.prompt!(Gum.write(header: 'Put one random choice per line', prompt_style: UI::PROMPT_STYLE,
|
|
18
|
+
header_style: UI::PROMPT_STYLE))
|
|
19
|
+
{ choices: raw.to_s.lines.map(&:chomp).reject(&:empty?) }
|
|
20
|
+
},
|
|
21
|
+
'choice' => lambda { |b|
|
|
22
|
+
raw = b.prompt!(Gum.write(header: 'Put one choice per line', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE))
|
|
23
|
+
{ values: raw.to_s.lines.map(&:chomp).reject(&:empty?) }
|
|
24
|
+
}
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.collect(builder, type)
|
|
28
|
+
params = collect_raw(builder, type)
|
|
29
|
+
validate!(type, params)
|
|
30
|
+
params
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate!(type, params)
|
|
34
|
+
return unless ParamSchema.known_type?(type)
|
|
35
|
+
return if ParamSchema.valid_params?(type, params)
|
|
36
|
+
|
|
37
|
+
raise SnippetCli::InvalidParamsError,
|
|
38
|
+
"Invalid params #{params.inspect} for var type '#{type}'"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.collect_raw(builder, type)
|
|
42
|
+
collector = COLLECTORS[type]
|
|
43
|
+
return collector.call(builder) if collector
|
|
44
|
+
|
|
45
|
+
case type
|
|
46
|
+
when 'date' then date(builder)
|
|
47
|
+
when 'shell' then shell(builder)
|
|
48
|
+
when 'script' then script(builder)
|
|
49
|
+
when 'form' then form(builder)
|
|
50
|
+
else {}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.collect_list(builder, item_name)
|
|
55
|
+
raw = builder.prompt!(Gum.write(header: "Put one #{item_name} per line", prompt_style: UI::PROMPT_STYLE,
|
|
56
|
+
header_style: UI::PROMPT_STYLE))
|
|
57
|
+
raw.to_s.lines.map(&:chomp).reject(&:empty?)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.shell(builder)
|
|
61
|
+
sh = builder.prompt!(Gum.filter(*builder.platform_shells, limit: 1, header: 'Select shell'))
|
|
62
|
+
cmd = builder.prompt!(Gum.input(placeholder: 'shell command', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE))
|
|
63
|
+
debug_trim(builder, cmd: cmd, shell: sh)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.script(builder)
|
|
67
|
+
gum = Gum.write(header: 'Script args — one per line',
|
|
68
|
+
placeholder: '/path/to/script', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE)
|
|
69
|
+
raw = builder.prompt!(gum)
|
|
70
|
+
params = { args: raw.to_s.lines.map(&:chomp).reject(&:empty?) }
|
|
71
|
+
params[:trim] = true if builder.confirm!('Trim whitespace from output?')
|
|
72
|
+
params
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.date(builder)
|
|
76
|
+
params = { format: builder.prompt!(Gum.input(placeholder: 'date format (e.g. %Y-%m-%d)',
|
|
77
|
+
prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE)) }
|
|
78
|
+
date_optional_params(builder, params)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
DATE_OPT_FIELDS = [
|
|
82
|
+
[:offset, 'Add an offset?', 'offset in seconds (e.g. 86400)', :to_i],
|
|
83
|
+
[:locale, 'Add a locale?', 'BCP47 locale (e.g. en-US, ja-JP)', nil],
|
|
84
|
+
[:tz, 'Add a timezone?', 'IANA timezone (e.g. America/New_York)', nil]
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
def self.date_optional_params(builder, params)
|
|
88
|
+
style = UI::PROMPT_STYLE
|
|
89
|
+
DATE_OPT_FIELDS.each do |key, prompt, ph, conv|
|
|
90
|
+
next unless builder.confirm!(prompt)
|
|
91
|
+
|
|
92
|
+
val = builder.prompt!(Gum.input(placeholder: ph, prompt_style: style, header_style: style))
|
|
93
|
+
params[key] = conv ? val.public_send(conv) : val
|
|
94
|
+
end
|
|
95
|
+
params
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.form(builder)
|
|
99
|
+
layout = form_layout(builder)
|
|
100
|
+
fields = FormFields.collect(builder, layout)
|
|
101
|
+
params = { layout: layout }
|
|
102
|
+
params[:fields] = fields if fields.any?
|
|
103
|
+
params
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.form_layout(builder)
|
|
107
|
+
ps = UI::PROMPT_STYLE
|
|
108
|
+
ph = 'Form layout (use [[field_name]] for fields)'
|
|
109
|
+
gum = if builder.confirm!('Multi-line form?')
|
|
110
|
+
Gum.write(header: ph, prompt_style: ps, header_style: ps)
|
|
111
|
+
else
|
|
112
|
+
Gum.input(placeholder: ph, prompt_style: ps, header_style: ps)
|
|
113
|
+
end
|
|
114
|
+
builder.prompt!(gum)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.debug_trim(builder, params)
|
|
118
|
+
params[:debug] = true if builder.confirm!('Enable debug mode?')
|
|
119
|
+
params[:trim] = true if builder.confirm!('Trim whitespace from output?')
|
|
120
|
+
params
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_class_method :collect_raw, :shell, :script, :date,
|
|
124
|
+
:date_optional_params, :form, :form_layout, :debug_trim
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gum'
|
|
4
|
+
require_relative 'wizard_helpers/prompt_helpers'
|
|
5
|
+
require_relative 'var_summary_renderer'
|
|
6
|
+
require_relative 'var_builder/name_collector'
|
|
7
|
+
require_relative 'var_builder/params'
|
|
8
|
+
|
|
9
|
+
module SnippetCli
|
|
10
|
+
module VarBuilder
|
|
11
|
+
extend WizardHelpers::PromptHelpers
|
|
12
|
+
|
|
13
|
+
VAR_TYPES = %w[echo shell date random choice script form clipboard].freeze
|
|
14
|
+
|
|
15
|
+
# Characters that break variable-to-mapping resolution. Add more here as needed.
|
|
16
|
+
PROHIBITED_CHARS = %w[-].freeze
|
|
17
|
+
|
|
18
|
+
SHELLS_BY_PLATFORM = {
|
|
19
|
+
macos: %w[sh bash pwsh nu],
|
|
20
|
+
linux: %w[sh bash pwsh nu],
|
|
21
|
+
windows: %w[cmd powershell pwsh wsl nu]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Collects Espanso variable definitions interactively. Raises WizardInterrupted on cancel.
|
|
25
|
+
# skip_initial_prompt: true skips "Add a variable?" and goes straight to collecting the first var.
|
|
26
|
+
# Returns { vars: Array, summary_clear: Proc }.
|
|
27
|
+
def self.run(skip_initial_prompt: false)
|
|
28
|
+
interactive_session(skip_initial_prompt: skip_initial_prompt)
|
|
29
|
+
rescue Interrupt
|
|
30
|
+
raise WizardInterrupted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.platform_shells
|
|
34
|
+
case RUBY_PLATFORM
|
|
35
|
+
when /darwin/ then SHELLS_BY_PLATFORM[:macos]
|
|
36
|
+
when /mswin|mingw|cygwin/ then SHELLS_BY_PLATFORM[:windows]
|
|
37
|
+
else SHELLS_BY_PLATFORM[:linux]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.collect_one_var(existing)
|
|
42
|
+
name = NameCollector.new(existing).collect
|
|
43
|
+
return nil if name.nil?
|
|
44
|
+
|
|
45
|
+
type = prompt!(Gum.filter(*VAR_TYPES, limit: 1, header: 'Variable type'))
|
|
46
|
+
{ name: name, type: type, params: Params.collect(self, type) }
|
|
47
|
+
end
|
|
48
|
+
private_class_method :collect_one_var
|
|
49
|
+
|
|
50
|
+
def self.interactive_session(skip_initial_prompt: false)
|
|
51
|
+
vars = []
|
|
52
|
+
loop do
|
|
53
|
+
break unless confirm_next?(vars, skip_initial_prompt)
|
|
54
|
+
|
|
55
|
+
append_var!(vars)
|
|
56
|
+
end
|
|
57
|
+
reorder_vars!(vars)
|
|
58
|
+
summary_clear = vars.empty? ? -> {} : VarSummaryRenderer.show(vars)
|
|
59
|
+
{ vars: vars, summary_clear: summary_clear }
|
|
60
|
+
end
|
|
61
|
+
private_class_method :interactive_session
|
|
62
|
+
|
|
63
|
+
def self.reorder_vars!(vars)
|
|
64
|
+
return if vars.size < 2
|
|
65
|
+
return unless confirm!('Reorder variables for evaluation order?')
|
|
66
|
+
|
|
67
|
+
loop do
|
|
68
|
+
selection = choose_var_to_move(vars)
|
|
69
|
+
break if selection.start_with?('Done')
|
|
70
|
+
|
|
71
|
+
move_var!(vars, selection)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :reorder_vars!
|
|
75
|
+
|
|
76
|
+
def self.choose_var_to_move(vars)
|
|
77
|
+
choices = vars.each_with_index.map { |v, i| "#{i + 1}. #{v[:name]} (#{v[:type]})" }
|
|
78
|
+
choices << 'Done — keep this order'
|
|
79
|
+
prompt!(Gum.choose(*choices, header: 'Select a variable to move:', header_style: UI::PROMPT_STYLE))
|
|
80
|
+
end
|
|
81
|
+
private_class_method :choose_var_to_move
|
|
82
|
+
|
|
83
|
+
def self.move_var!(vars, selection)
|
|
84
|
+
from_idx = selection.match(/^(\d+)\./)[1].to_i - 1
|
|
85
|
+
item = vars.delete_at(from_idx)
|
|
86
|
+
vars.insert(choose_target_position(vars, item[:name]), item)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :move_var!
|
|
89
|
+
|
|
90
|
+
def self.choose_target_position(vars, name)
|
|
91
|
+
positions = vars.each_with_index.map { |v, i| "#{i + 1}. Before #{v[:name]}" }
|
|
92
|
+
positions << "#{vars.size + 1}. At end"
|
|
93
|
+
result = prompt!(Gum.choose(*positions, header: "Move \"#{name}\" to:", header_style: UI::PROMPT_STYLE))
|
|
94
|
+
result.match(/^(\d+)\./)[1].to_i - 1
|
|
95
|
+
end
|
|
96
|
+
private_class_method :choose_target_position
|
|
97
|
+
|
|
98
|
+
def self.confirm_next?(vars, skip_initial_prompt)
|
|
99
|
+
return true if vars.empty? && skip_initial_prompt
|
|
100
|
+
|
|
101
|
+
question = confirm_question(vars, skip_initial_prompt)
|
|
102
|
+
if vars.empty?
|
|
103
|
+
confirm!(question)
|
|
104
|
+
else
|
|
105
|
+
list_confirm!('variable', VarSummaryRenderer.rows(vars), %w[Name Type], question)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
private_class_method :confirm_next?
|
|
109
|
+
|
|
110
|
+
def self.confirm_question(vars, skip_initial_prompt)
|
|
111
|
+
return 'Add a variable?' if vars.empty? && !skip_initial_prompt
|
|
112
|
+
|
|
113
|
+
'Add another variable?'
|
|
114
|
+
end
|
|
115
|
+
private_class_method :confirm_question
|
|
116
|
+
|
|
117
|
+
def self.append_var!(vars)
|
|
118
|
+
var = collect_one_var(vars)
|
|
119
|
+
return if var.nil?
|
|
120
|
+
|
|
121
|
+
vars << var
|
|
122
|
+
end
|
|
123
|
+
private_class_method :append_var!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gum'
|
|
4
|
+
require_relative 'ui'
|
|
5
|
+
require_relative 'cursor_helper'
|
|
6
|
+
require_relative 'form_field_parser'
|
|
7
|
+
|
|
8
|
+
module SnippetCli
|
|
9
|
+
# Pure display/formatting logic for the VarBuilder summary screen.
|
|
10
|
+
# Handles row computation, UI.note output, Gum.table rendering, and cursor erase lambda.
|
|
11
|
+
module VarSummaryRenderer
|
|
12
|
+
# Returns display rows for the given vars array.
|
|
13
|
+
# Form vars are expanded into dot-notation field rows; all others are [name, type].
|
|
14
|
+
def self.rows(vars)
|
|
15
|
+
vars.flat_map do |var|
|
|
16
|
+
if var[:type] == 'form'
|
|
17
|
+
form_field_names(var[:params][:layout]).map { |field| ["#{var[:name]}.#{field}", 'form field'] }
|
|
18
|
+
else
|
|
19
|
+
[[var[:name], var[:type]]]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Renders the summary note + table. Returns a lambda that erases the output.
|
|
25
|
+
def self.show(vars)
|
|
26
|
+
display_rows = rows(vars)
|
|
27
|
+
names = display_rows.map { |name, _type| "{{#{name}}}" }.join(', ')
|
|
28
|
+
text = "Reference your variables in the replacement using {{var}} syntax:\n#{names}"
|
|
29
|
+
UI.note(text)
|
|
30
|
+
puts
|
|
31
|
+
Gum.table(display_rows, columns: %w[Name Type], print: true)
|
|
32
|
+
puts
|
|
33
|
+
build_erase(text, display_rows)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.form_field_names(layout)
|
|
37
|
+
FormFieldParser.extract(layout)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :form_field_names
|
|
40
|
+
|
|
41
|
+
def self.build_erase(text, display_rows)
|
|
42
|
+
# UI.note lines + blank + table (top border + header + separator + data rows + bottom border) + blank
|
|
43
|
+
total = text.lines.count + 1 + display_rows.length + 4 + 1
|
|
44
|
+
CursorHelper.build_erase_lambda(total)
|
|
45
|
+
end
|
|
46
|
+
private_class_method :build_erase
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'form_field_parser'
|
|
4
|
+
|
|
5
|
+
module SnippetCli
|
|
6
|
+
# Checks variable usage in a single snippet match.
|
|
7
|
+
# Detects declared-but-unused vars and used-but-undeclared {{refs}}.
|
|
8
|
+
module VarUsageChecker
|
|
9
|
+
VAR_REF_PATTERN = /\{\{(\w+(?:\.\w+)?)\}\}/
|
|
10
|
+
REPLACEMENT_KEYS = %i[replace html markdown image_path].freeze
|
|
11
|
+
|
|
12
|
+
# Returns a hash { unused: [...], undeclared: [...] } of variable name arrays.
|
|
13
|
+
# vars: array of var hashes (symbol or string keyed)
|
|
14
|
+
# replacement: hash with one of :replace, :html, :markdown, :image_path
|
|
15
|
+
def self.match_warnings(vars, replacement, global_var_names: [])
|
|
16
|
+
declared = extract_names(vars)
|
|
17
|
+
used = extract_refs(replacement)
|
|
18
|
+
known = declared + Array(global_var_names)
|
|
19
|
+
{
|
|
20
|
+
unused: declared - used,
|
|
21
|
+
undeclared: used - known
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.extract_names(vars)
|
|
26
|
+
Array(vars).flat_map do |v|
|
|
27
|
+
name = v[:name] || v['name']
|
|
28
|
+
next [] unless name
|
|
29
|
+
|
|
30
|
+
(v[:type] || v['type']).to_s == 'form' ? form_field_refs(name, v) : [name]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_class_method :extract_names
|
|
34
|
+
|
|
35
|
+
def self.form_field_refs(name, var)
|
|
36
|
+
params = var[:params] || var['params'] || {}
|
|
37
|
+
layout = params[:layout] || params['layout'] || ''
|
|
38
|
+
FormFieldParser.extract(layout).map { |field| "#{name}.#{field}" }
|
|
39
|
+
end
|
|
40
|
+
private_class_method :form_field_refs
|
|
41
|
+
|
|
42
|
+
def self.extract_refs(replacement)
|
|
43
|
+
text = REPLACEMENT_KEYS.filter_map { |k| replacement[k] }.join
|
|
44
|
+
text.scan(VAR_REF_PATTERN).flatten.uniq
|
|
45
|
+
end
|
|
46
|
+
private_class_method :extract_refs
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'yaml_param_renderer'
|
|
4
|
+
|
|
5
|
+
module SnippetCli
|
|
6
|
+
# Renders a single Espanso var entry as YAML lines.
|
|
7
|
+
# Used by SnippetBuilder and commands/vars to share a consistent rendering strategy.
|
|
8
|
+
module VarYamlRenderer
|
|
9
|
+
# Returns an array of YAML lines for one var hash ({ name:, type:, params: }).
|
|
10
|
+
def self.var_lines(var)
|
|
11
|
+
lines = [" - name: #{var[:name]}", " type: #{var[:type]}"]
|
|
12
|
+
params = var[:params]
|
|
13
|
+
return lines unless params&.any?
|
|
14
|
+
|
|
15
|
+
lines << ' params:'
|
|
16
|
+
params.each { |key, val| lines.concat(YamlParamRenderer.lines(key, val, ' ')) }
|
|
17
|
+
lines
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'var_yaml_renderer'
|
|
4
|
+
|
|
5
|
+
module SnippetCli
|
|
6
|
+
# Builds the vars: block as an array of YAML lines.
|
|
7
|
+
# indent: is prepended only to the vars: header; VarYamlRenderer owns var-entry indentation.
|
|
8
|
+
module VarsBlockRenderer
|
|
9
|
+
def self.render(vars, indent: '')
|
|
10
|
+
lines = ["#{indent}vars:"]
|
|
11
|
+
vars.each { |var| lines.concat(VarYamlRenderer.var_lines(var)) }
|
|
12
|
+
lines
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/snippet_cli/version.rb
CHANGED