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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -15
  3. data/exe/snippet_cli +45 -11
  4. data/lib/snippet_cli/Espanso_Merged_Matchfile_Schema.json +889 -0
  5. data/lib/snippet_cli/banner.rb +16 -0
  6. data/lib/snippet_cli/commands/check.rb +49 -0
  7. data/lib/snippet_cli/commands/conflict.rb +75 -0
  8. data/lib/snippet_cli/commands/new.rb +16 -102
  9. data/lib/snippet_cli/commands/vars.rb +58 -0
  10. data/lib/snippet_cli/commands/version.rb +17 -0
  11. data/lib/snippet_cli/conflict_detector.rb +71 -0
  12. data/lib/snippet_cli/cursor_helper.rb +20 -0
  13. data/lib/snippet_cli/espanso_config.rb +26 -0
  14. data/lib/snippet_cli/file_helper.rb +16 -0
  15. data/lib/snippet_cli/file_validator.rb +33 -0
  16. data/lib/snippet_cli/file_writer.rb +12 -0
  17. data/lib/snippet_cli/form_field_parser.rb +13 -0
  18. data/lib/snippet_cli/global_vars_formatter.rb +63 -0
  19. data/lib/snippet_cli/global_vars_writer.rb +29 -0
  20. data/lib/snippet_cli/gum_theme.rb +39 -0
  21. data/lib/snippet_cli/hash_utils.rb +21 -0
  22. data/lib/snippet_cli/match_file_writer.rb +26 -0
  23. data/lib/snippet_cli/match_validator.rb +33 -0
  24. data/lib/snippet_cli/new_workflow.rb +98 -0
  25. data/lib/snippet_cli/replacement_text_collector.rb +55 -0
  26. data/lib/snippet_cli/replacement_validator.rb +35 -0
  27. data/lib/snippet_cli/replacement_wizard.rb +100 -0
  28. data/lib/snippet_cli/schema_validator.rb +27 -0
  29. data/lib/snippet_cli/snippet_builder.rb +133 -0
  30. data/lib/snippet_cli/string_helper.rb +11 -0
  31. data/lib/snippet_cli/table_formatter.rb +37 -0
  32. data/lib/snippet_cli/trigger_resolver.rb +67 -0
  33. data/lib/snippet_cli/ui.rb +90 -0
  34. data/lib/snippet_cli/var_builder/form_fields.rb +54 -0
  35. data/lib/snippet_cli/var_builder/name_collector.rb +69 -0
  36. data/lib/snippet_cli/var_builder/param_schema.rb +71 -0
  37. data/lib/snippet_cli/var_builder/params.rb +127 -0
  38. data/lib/snippet_cli/var_builder.rb +125 -0
  39. data/lib/snippet_cli/var_summary_renderer.rb +48 -0
  40. data/lib/snippet_cli/var_usage_checker.rb +48 -0
  41. data/lib/snippet_cli/var_yaml_renderer.rb +20 -0
  42. data/lib/snippet_cli/vars_block_renderer.rb +15 -0
  43. data/lib/snippet_cli/version.rb +3 -1
  44. data/lib/snippet_cli/wizard_context.rb +13 -0
  45. data/lib/snippet_cli/wizard_helpers/error_handler.rb +20 -0
  46. data/lib/snippet_cli/wizard_helpers/match_file_selector.rb +32 -0
  47. data/lib/snippet_cli/wizard_helpers/prompt_helpers.rb +57 -0
  48. data/lib/snippet_cli/wizard_helpers/validation_loop.rb +33 -0
  49. data/lib/snippet_cli/wizard_helpers.rb +8 -0
  50. data/lib/snippet_cli/yaml_line_resolver.rb +46 -0
  51. data/lib/snippet_cli/yaml_loader.rb +19 -0
  52. data/lib/snippet_cli/yaml_param_renderer.rb +33 -0
  53. data/lib/snippet_cli/yaml_scalar.rb +48 -0
  54. data/lib/snippet_cli.rb +44 -1
  55. metadata +134 -101
  56. data/.gitignore +0 -11
  57. data/.rspec +0 -3
  58. data/.travis.yml +0 -9
  59. data/CODE_OF_CONDUCT.md +0 -74
  60. data/Gemfile +0 -17
  61. data/Rakefile +0 -10
  62. data/bin/console +0 -14
  63. data/bin/setup +0 -8
  64. data/lib/Setup.rb +0 -76
  65. data/lib/banner.rb +0 -16
  66. data/lib/snippet_cli/cli.rb +0 -62
  67. data/lib/snippet_cli/command.rb +0 -121
  68. data/lib/snippet_cli/commands/info.md +0 -20
  69. data/lib/snippet_cli/commands/info.rb +0 -36
  70. data/lib/snippet_cli/commands/setup.rb +0 -108
  71. data/lib/snippet_cli/templates/.gitkeep +0 -1
  72. data/lib/snippet_cli/templates/config/.gitkeep +0 -1
  73. data/lib/snippet_cli/templates/create/.gitkeep +0 -1
  74. data/lib/snippet_cli/templates/info/.gitkeep +0 -1
  75. data/lib/snippet_cli/templates/setup/.gitkeep +0 -1
  76. data/lib/snippet_generator.rb +0 -85
  77. data/snippet_cli-0.1.0.gem +0 -0
  78. data/snippet_cli-0.1.1.gem +0 -0
  79. data/snippet_cli-0.1.2.gem +0 -0
  80. data/snippet_cli-0.1.3.gem +0 -0
  81. data/snippet_cli-0.1.4.gem +0 -0
  82. data/snippet_cli-0.1.5.gem +0 -0
  83. data/snippet_cli-0.1.6.gem +0 -0
  84. data/snippet_cli-0.1.7.gem +0 -0
  85. data/snippet_cli-0.1.8.gem +0 -0
  86. data/snippet_cli-0.1.9.gem +0 -0
  87. data/snippet_cli-0.2.0.gem +0 -0
  88. data/snippet_cli-0.2.1.gem +0 -0
  89. data/snippet_cli-0.2.2.gem +0 -0
  90. data/snippet_cli-0.2.3.gem +0 -0
  91. data/snippet_cli-0.2.4.gem +0 -0
  92. data/snippet_cli-0.2.6.gem +0 -0
  93. data/snippet_cli-0.2.7.gem +0 -0
  94. data/snippet_cli-0.2.8.gem +0 -0
  95. 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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SnippetCli
2
- VERSION = "0.3.6"
4
+ VERSION = '0.5.2'
3
5
  end