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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Shared hash-manipulation utilities used across validators.
5
+ module HashUtils
6
+ # Recursively converts symbol keys to string keys so the JSON schema
7
+ # validator can match property names. Symbol values are also stringified.
8
+ def self.stringify_keys_deep(obj)
9
+ case obj
10
+ when Hash
11
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
12
+ when Array
13
+ obj.map { |item| stringify_keys_deep(item) }
14
+ when Symbol
15
+ obj.to_s
16
+ else
17
+ obj
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'file_helper'
4
+ require_relative 'file_writer'
5
+ require_relative 'string_helper'
6
+
7
+ module SnippetCli
8
+ # Appends a generated snippet YAML string to an Espanso match file.
9
+ # Uses string-level append (not YAML round-trip) to preserve formatting.
10
+ module MatchFileWriter
11
+ # Appends the snippet to the given file, indenting it by 2 spaces
12
+ # to sit under the top-level `matches:` key.
13
+ def self.append(file_path, snippet_yaml)
14
+ existing = FileHelper.read_or_empty(file_path)
15
+ indented = snippet_yaml.lines.map { |line| " #{line}" }.join
16
+ content = build_content(existing, indented)
17
+ FileWriter.write(file_path, content)
18
+ end
19
+
20
+ def self.build_content(existing, indented)
21
+ prefix = existing.strip.empty? ? "matches:\n" : StringHelper.ensure_trailing_newline(existing)
22
+ StringHelper.ensure_trailing_newline("#{prefix}#{indented}")
23
+ end
24
+ private_class_method :build_content
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hash_utils'
4
+ require_relative 'schema_validator'
5
+
6
+ module SnippetCli
7
+ # Validates an Espanso match entry (as a Ruby hash) against the vendored
8
+ # merged schema at vendor/espanso-schema-json/schemas/Espanso_Merged_Matchfile_Schema.json.
9
+ # Uses json_schemer which supports JSON Schema draft-07 (required for
10
+ # the if/then conditionals used in the Espanso match schema).
11
+ module MatchValidator
12
+ # Returns true if the data is valid against the Espanso match schema.
13
+ # Accepts symbol or string keys — keys are stringified before validation.
14
+ # The merged schema validates a full matchfile, so the entry is wrapped in
15
+ # a { "matches" => [...] } envelope before validation.
16
+ def self.valid?(data)
17
+ SchemaValidator.valid?(wrap(HashUtils.stringify_keys_deep(data)))
18
+ end
19
+
20
+ # Returns an array of human-readable error strings.
21
+ # Empty array means the data is valid.
22
+ def self.errors(data)
23
+ SchemaValidator.validate(wrap(HashUtils.stringify_keys_deep(data))).map do |error|
24
+ error['error'] || error.fetch('type', 'validation error')
25
+ end
26
+ end
27
+
28
+ def self.wrap(entry)
29
+ { 'matches' => [entry] }
30
+ end
31
+ private_class_method :wrap
32
+ end
33
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'var_builder'
4
+ require_relative 'snippet_builder'
5
+ require_relative 'ui'
6
+ require_relative 'wizard_helpers/match_file_selector'
7
+ require_relative 'wizard_helpers/error_handler'
8
+ require_relative 'wizard_context'
9
+ require_relative 'trigger_resolver'
10
+ require_relative 'replacement_wizard'
11
+ require_relative 'espanso_config'
12
+ require_relative 'match_file_writer'
13
+ require_relative 'global_vars_writer'
14
+
15
+ module SnippetCli
16
+ # Thin orchestrator for the new-snippet wizard.
17
+ # Sequences collaborators (TriggerResolver, ReplacementWizard, SnippetBuilder)
18
+ # but contains no Gum/UI calls or business rules itself.
19
+ class NewWorkflow
20
+ include WizardHelpers::MatchFileSelector
21
+ include WizardHelpers::ErrorHandler
22
+ include TriggerResolver
23
+
24
+ def run(opts)
25
+ handle_errors(ValidationError, EspansoConfigError, YamlScalar::InvalidCharacterError, NoMatchFilesError) do
26
+ context = prepare_context(opts)
27
+ yaml, summary_clear = build_snippet(opts, context)
28
+ deliver_snippet(yaml, context, summary_clear)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def prepare_context(opts)
35
+ pipe_output = SnippetCli.pipe_output
36
+ return WizardContext.new(pipe_output: pipe_output) unless opts[:save]
37
+
38
+ chosen, save_path = pick_match_file
39
+ @file_note_clear = UI.transient_note("Using #{chosen}") if EspansoConfig.match_files.size == 1
40
+ global_var_names = GlobalVarsWriter.read_names(save_path)
41
+ WizardContext.new(save_path: save_path, global_var_names: global_var_names, pipe_output: pipe_output)
42
+ end
43
+
44
+ def build_snippet(opts, context)
45
+ resolution = resolve_triggers(opts)
46
+ @file_note_clear&.call
47
+ replacement_hash, summary_clear = resolve_replacement(
48
+ no_vars: opts[:no_vars], bare: opts[:bare], global_var_names: context.global_var_names
49
+ )
50
+ [assemble_yaml(resolution, replacement_hash), summary_clear]
51
+ end
52
+
53
+ def assemble_yaml(resolution, replacement_hash)
54
+ SnippetBuilder.build(
55
+ triggers: resolution.list,
56
+ is_regex: resolution.is_regex,
57
+ single_trigger: resolution.single_trigger,
58
+ **replacement_hash
59
+ )
60
+ end
61
+
62
+ def resolve_replacement(no_vars: false, bare: false, global_var_names: [])
63
+ wizard = ReplacementWizard.new
64
+ if bare
65
+ [{ replace: wizard.collect_plain_replace, vars: [], label: nil, comment: nil }, nil]
66
+ elsif no_vars
67
+ [collect_no_vars_replacement(wizard, global_var_names: global_var_names), nil]
68
+ else
69
+ collect_full_replacement(wizard, global_var_names: global_var_names)
70
+ end
71
+ end
72
+
73
+ def collect_no_vars_replacement(wizard, global_var_names: [])
74
+ replacement = wizard.collect([], global_var_names: global_var_names)
75
+ advanced = wizard.collect_advanced_options
76
+ { vars: [] }.merge(advanced).merge(replacement)
77
+ end
78
+
79
+ def collect_full_replacement(wizard, global_var_names: [])
80
+ result = VarBuilder.run
81
+ vars, summary_clear = result.values_at(:vars, :summary_clear)
82
+ replacement = wizard.collect(vars, global_var_names: global_var_names)
83
+ advanced = wizard.collect_advanced_options
84
+ [{ vars: vars }.merge(advanced).merge(replacement), summary_clear]
85
+ end
86
+
87
+ def deliver_snippet(yaml, context, summary_clear)
88
+ summary_clear&.call
89
+ write_save(yaml, context.save_path) if context.save_path
90
+ UI.deliver(yaml, label: 'Snippet', context: context)
91
+ end
92
+
93
+ def write_save(yaml, save_path)
94
+ MatchFileWriter.append(save_path, yaml)
95
+ UI.success("Saved to #{File.basename(save_path)}")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gum'
4
+ require_relative 'wizard_helpers/prompt_helpers'
5
+ require_relative 'wizard_helpers/validation_loop'
6
+
7
+ module SnippetCli
8
+ # Collects replacement text from interactive Gum prompts.
9
+ # Handles plain-text (single and multi-line) and alt-type (markdown, html, image_path) inputs.
10
+ module ReplacementTextCollector
11
+ include WizardHelpers::PromptHelpers
12
+ include WizardHelpers::ValidationLoop
13
+
14
+ EMPTY_REPLACE_WARNING = 'Replace value is empty. Continue with no replacement text?'
15
+
16
+ private
17
+
18
+ def collect_replace(_vars)
19
+ loop do
20
+ value = prompt!(gum_replace_input(confirm!('Multi-line replacement?')))
21
+ next if value.strip.empty? && !confirm!(EMPTY_REPLACE_WARNING)
22
+
23
+ return value
24
+ end
25
+ end
26
+
27
+ def gum_replace_input(multiline)
28
+ style = UI::PROMPT_STYLE
29
+ if multiline
30
+ Gum.write(header: 'Replacement', placeholder: 'Type expansion text...',
31
+ prompt_style: style, header_style: style)
32
+ else
33
+ Gum.input(placeholder: 'Replacement text', prompt_style: style, header_style: style)
34
+ end
35
+ end
36
+
37
+ def collect_alt_value(type)
38
+ prompt_non_empty_replace { prompt_alt_input(type) }
39
+ end
40
+
41
+ def prompt_alt_input(type)
42
+ style = UI::PROMPT_STYLE
43
+ if type == :image_path
44
+ prompt!(Gum.input(placeholder: '/path/to/image.png', prompt_style: style, header_style: style))
45
+ else
46
+ prompt!(Gum.write(header: type.to_s.capitalize, placeholder: "Enter #{type}...",
47
+ prompt_style: style, header_style: style))
48
+ end
49
+ end
50
+
51
+ def prompt_non_empty_replace(&)
52
+ prompt_non_empty('Replacement cannot be empty. Please enter replacement text.', &)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ui'
4
+ require_relative 'var_usage_checker'
5
+ require_relative 'wizard_helpers/prompt_helpers'
6
+
7
+ module SnippetCli
8
+ # Validates replacement data against declared vars.
9
+ # Returns a clear lambda when the user wants to retry, nil when they accept or there are no issues.
10
+ module ReplacementValidator
11
+ include WizardHelpers::PromptHelpers
12
+
13
+ private
14
+
15
+ def var_error_clear(vars, replacement, global_var_names: [])
16
+ result = VarUsageChecker.match_warnings(vars, replacement, global_var_names: global_var_names)
17
+ return nil if result[:unused].empty? && result[:undeclared].empty?
18
+
19
+ display_var_warnings(result)
20
+ return nil if confirm!('Are you sure you want to continue?')
21
+
22
+ -> {}
23
+ end
24
+
25
+ def display_var_warnings(result)
26
+ result[:unused].each do |name|
27
+ UI.warning("Variable '#{name}' is declared but unused — add {{#{name}}} to the replacement text.")
28
+ end
29
+ result[:undeclared].each do |name|
30
+ UI.warning("'{{#{name}}}' appears in the replacement but was not declared as a variable. " \
31
+ "Remove {{#{name}}} from the replacement.")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gum'
4
+ require_relative 'ui'
5
+ require_relative 'wizard_helpers/prompt_helpers'
6
+ require_relative 'wizard_helpers/validation_loop'
7
+ require_relative 'replacement_text_collector'
8
+ require_relative 'replacement_validator'
9
+
10
+ module SnippetCli
11
+ # Handles all interactive replacement collection for the new-snippet wizard.
12
+ # Keeps Gum/UI calls out of NewWorkflow so orchestration and domain logic
13
+ # can be read and tested without UI concerns.
14
+ class ReplacementWizard
15
+ include WizardHelpers::PromptHelpers
16
+ include WizardHelpers::ValidationLoop
17
+ include ReplacementTextCollector
18
+ include ReplacementValidator
19
+
20
+ # Collects a replacement hash (type + value) with var validation.
21
+ # Returns e.g. { replace: '...' } or { markdown: '...' } or { image_path: '...', vars: [] }.
22
+ def collect(vars, global_var_names: [])
23
+ collect_replacement(vars, global_var_names: global_var_names)
24
+ end
25
+
26
+ # Collects advanced snippet options (label, comment, search_terms, word, propagate_case).
27
+ # Returns a hash with all keys present (nil/false/[] for declined options).
28
+ def collect_advanced_options
29
+ return { label: nil, comment: nil, search_terms: [] } unless confirm!('Show advanced options?')
30
+
31
+ advanced_options_hash
32
+ end
33
+
34
+ # Collects plain replacement text only (used for --bare mode).
35
+ def collect_plain_replace
36
+ collect_replace([])
37
+ end
38
+
39
+ private
40
+
41
+ def advanced_options_hash
42
+ {
43
+ label: optional_label,
44
+ comment: optional_comment,
45
+ search_terms: collect_search_terms,
46
+ word: (true if confirm!('Word trigger?')),
47
+ propagate_case: (true if confirm!('Propagate case?'))
48
+ }
49
+ end
50
+
51
+ def optional_label
52
+ optional_prompt('Add a label?') do
53
+ prompt!(Gum.input(placeholder: 'Label', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE))
54
+ end
55
+ end
56
+
57
+ def optional_comment
58
+ optional_prompt('Add a comment?') do
59
+ prompt!(Gum.input(placeholder: 'Comment', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE))
60
+ end
61
+ end
62
+
63
+ def collect_replacement(vars, global_var_names: [])
64
+ if confirm!('Use a non-plaintext replacement type?')
65
+ select_alt_type(vars, global_var_names: global_var_names)
66
+ else
67
+ collect_with_check(vars, global_var_names: global_var_names) { { replace: collect_replace(vars) } }
68
+ end
69
+ end
70
+
71
+ def select_alt_type(vars, global_var_names: [])
72
+ type = prompt!(Gum.filter('markdown', 'html', 'image_path', limit: 1, header: 'Replacement type'))
73
+ return select_alt_type(vars, global_var_names: global_var_names) if image_path_discard_declined?(type, vars)
74
+
75
+ if type == 'image_path'
76
+ collect_alt_with_check(:image_path, [], global_var_names: global_var_names).merge(vars: [])
77
+ else
78
+ collect_alt_with_check(type.to_sym, vars, global_var_names: global_var_names)
79
+ end
80
+ end
81
+
82
+ def image_path_discard_declined?(type, vars)
83
+ return false unless type == 'image_path' && vars.any?
84
+
85
+ UI.info('image_path does not support vars — they will be dropped.')
86
+ !confirm!('Drop vars and continue with image_path?')
87
+ end
88
+
89
+ def collect_alt_with_check(type, vars, global_var_names: [])
90
+ collect_with_check(vars, global_var_names: global_var_names) { { type => collect_alt_value(type) } }
91
+ end
92
+
93
+ def collect_with_check(vars, global_var_names: [])
94
+ prompt_until_valid do
95
+ replacement = yield
96
+ [replacement, var_error_clear(vars, replacement, global_var_names: global_var_names)]
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module SnippetCli
6
+ # Loads the vendored Espanso merged matchfile schema once and exposes
7
+ # valid?/validate for use by MatchValidator and FileValidator.
8
+ # Callers are responsible for stringifying keys before passing data.
9
+ module SchemaValidator
10
+ SCHEMA_PATH = File.expand_path(
11
+ 'Espanso_Merged_Matchfile_Schema.json', __dir__
12
+ ).freeze
13
+
14
+ def self.valid?(data)
15
+ schemer.valid?(data)
16
+ end
17
+
18
+ def self.validate(data)
19
+ schemer.validate(data)
20
+ end
21
+
22
+ def self.schemer
23
+ @schemer ||= JSONSchemer.schema(Pathname.new(SCHEMA_PATH))
24
+ end
25
+ private_class_method :schemer
26
+ end
27
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'match_validator'
4
+ require_relative 'vars_block_renderer'
5
+ require_relative 'yaml_param_renderer'
6
+ require_relative 'yaml_scalar'
7
+
8
+ module SnippetCli
9
+ # Raised when the match data fails schema validation.
10
+ class ValidationError < StandardError; end
11
+
12
+ module SnippetBuilder
13
+ # Builds an Espanso match YAML entry from the given parameters.
14
+ # Validates against the Espanso match JSON schema before generating YAML.
15
+ # Raises ValidationError on failure.
16
+ def self.build(**opts)
17
+ validate!(opts)
18
+ render_yaml(opts)
19
+ end
20
+
21
+ def self.validate!(opts)
22
+ schema_errors = MatchValidator.errors(to_match_hash(opts))
23
+ return if schema_errors.empty?
24
+
25
+ raise ValidationError, "Schema validation failed:\n#{schema_errors.map { |err| " - #{err}" }.join("\n")}"
26
+ end
27
+ private_class_method :validate!
28
+
29
+ def self.render_yaml(opts)
30
+ lines = trigger_lines(opts[:triggers], opts[:is_regex], opts[:single_trigger])
31
+ lines.concat(vars_lines(opts[:vars])) if opts[:vars]&.any?
32
+ lines.concat(replacement_lines(opts))
33
+ append_optional_fields(lines, opts)
34
+ "#{lines.join("\n")}\n"
35
+ end
36
+ private_class_method :render_yaml
37
+
38
+ def self.replacement_lines(opts)
39
+ return replace_lines(opts[:replace]) if opts[:replace]
40
+ return [" image_path: #{YamlScalar.quote(opts[:image_path])}"] if opts[:image_path]
41
+ return block_scalar_lines('html', opts[:html]) if opts[:html]
42
+ return block_scalar_lines('markdown', opts[:markdown]) if opts[:markdown]
43
+
44
+ []
45
+ end
46
+ private_class_method :replacement_lines
47
+
48
+ def self.block_scalar_lines(key, val)
49
+ YamlParamRenderer.scalar_lines(key, val, ' ')
50
+ end
51
+ private_class_method :block_scalar_lines
52
+
53
+ def self.append_optional_fields(lines, opts)
54
+ append_label_and_comment(lines, opts)
55
+ append_search_terms(lines, opts[:search_terms])
56
+ append_trigger_modifiers(lines, opts)
57
+ end
58
+ private_class_method :append_optional_fields
59
+
60
+ def self.append_label_and_comment(lines, opts)
61
+ lines << " label: #{YamlScalar.quote(opts[:label])}" if opts[:label]&.then { !it.empty? }
62
+ lines << " comment: #{YamlScalar.quote(opts[:comment])}" if opts[:comment]&.then { !it.empty? }
63
+ end
64
+ private_class_method :append_label_and_comment
65
+
66
+ def self.append_trigger_modifiers(lines, opts)
67
+ return if opts[:image_path]
68
+
69
+ lines << ' word: true' if opts[:word]
70
+ lines << ' propagate_case: true' if opts[:propagate_case]
71
+ end
72
+ private_class_method :append_trigger_modifiers
73
+
74
+ def self.append_search_terms(lines, terms)
75
+ return unless terms&.any?
76
+
77
+ lines << ' search_terms:'
78
+ terms.each { |t| lines << " - #{YamlScalar.quote(t)}" }
79
+ end
80
+ private_class_method :append_search_terms
81
+
82
+ def self.trigger_lines(triggers, is_regex, single_trigger)
83
+ if is_regex
84
+ ["- regex: #{YamlScalar.quote(triggers.first)}"]
85
+ elsif single_trigger
86
+ ["- trigger: #{YamlScalar.quote(triggers.first)}"]
87
+ else
88
+ ['- triggers:'] + triggers.map { |t| " - #{YamlScalar.quote(t)}" }
89
+ end
90
+ end
91
+ private_class_method :trigger_lines
92
+
93
+ def self.vars_lines(vars)
94
+ VarsBlockRenderer.render(vars, indent: ' ')
95
+ end
96
+ private_class_method :vars_lines
97
+
98
+ def self.replace_lines(str)
99
+ block_scalar_lines('replace', str)
100
+ end
101
+ private_class_method :replace_lines
102
+
103
+ def self.to_match_hash(opts)
104
+ hash = build_trigger_hash(opts)
105
+ merge_optional(hash, opts, :replace, :image_path, :html, :markdown, :vars, :label, :comment, :search_terms)
106
+ merge_optional(hash, opts, :word, :propagate_case) unless opts[:image_path]
107
+ hash
108
+ end
109
+ private_class_method :to_match_hash
110
+
111
+ def self.merge_optional(hash, opts, *keys)
112
+ keys.each { |key| hash[key] = opts[key] unless skip_key?(key, opts[key]) }
113
+ hash
114
+ end
115
+ private_class_method :merge_optional
116
+
117
+ def self.skip_key?(key, val)
118
+ val.nil? || (val.is_a?(Array) && val.empty?) || (val.is_a?(String) && val.empty? && key != :replace)
119
+ end
120
+ private_class_method :skip_key?
121
+
122
+ def self.build_trigger_hash(opts)
123
+ if opts[:is_regex]
124
+ { regex: opts[:triggers].first }
125
+ elsif opts[:single_trigger]
126
+ { trigger: opts[:triggers].first }
127
+ else
128
+ { triggers: opts[:triggers] }
129
+ end
130
+ end
131
+ private_class_method :build_trigger_hash
132
+ end
133
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Utility methods for string manipulation.
5
+ module StringHelper
6
+ # Returns str with a trailing newline, appending one if not already present.
7
+ def self.ensure_trailing_newline(str)
8
+ str.end_with?("\n") ? str : "#{str}\n"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ module TableFormatter
5
+ def self.render(rows, headers:)
6
+ widths = column_widths(rows, headers)
7
+ lines = [
8
+ border_line(widths, left: '╭', mid: '┬', right: '╮'),
9
+ data_line(headers, widths),
10
+ border_line(widths, left: '├', mid: '┼', right: '┤'),
11
+ *rows.map { |row| data_line(row, widths) },
12
+ border_line(widths, left: '╰', mid: '┴', right: '╯')
13
+ ]
14
+ lines.map { |line| colorize(line) }.join("\n")
15
+ end
16
+
17
+ def self.column_widths(rows, headers)
18
+ headers.each_with_index.map { |h, i| [h.length, *rows.map { |r| r[i].to_s.length }].max }
19
+ end
20
+ private_class_method :column_widths
21
+
22
+ def self.border_line(widths, left:, mid:, right:)
23
+ "#{left}#{widths.map { |w| '─' * (w + 2) }.join(mid)}#{right}"
24
+ end
25
+ private_class_method :border_line
26
+
27
+ def self.data_line(cells, widths)
28
+ "│#{cells.each_with_index.map { |cell, i| " #{cell.to_s.ljust(widths[i])} " }.join('│')}│"
29
+ end
30
+ private_class_method :data_line
31
+
32
+ def self.colorize(line)
33
+ "\e[97m#{line}\e[0m"
34
+ end
35
+ private_class_method :colorize
36
+ end
37
+ end
@@ -0,0 +1,67 @@
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
+ # Resolves trigger input from CLI flags or interactive prompts.
9
+ module TriggerResolver
10
+ TriggerResolution = Struct.new(:list, :is_regex, :single_trigger)
11
+
12
+ include WizardHelpers::PromptHelpers
13
+ include WizardHelpers::ValidationLoop
14
+
15
+ RUST_REGEX_GUIDANCE = "Espanso uses Rust Regex syntax. Ensure this is a valid Rust regex.\n" \
16
+ 'https://docs.rs/regex/1.1.8/regex/#syntax'
17
+
18
+ private
19
+
20
+ def resolve_triggers(opts)
21
+ resolve_triggers_interactively(opts)
22
+ end
23
+
24
+ def resolve_triggers_interactively(_opts)
25
+ type = prompt!(Gum.choose('regular', 'regex', header: "Trigger type?\n", header_style: UI::PROMPT_STYLE))
26
+ list, is_regex = collect_triggers(type)
27
+ TriggerResolution.new(list, is_regex, false)
28
+ end
29
+
30
+ def collect_triggers(type)
31
+ if type == 'regex'
32
+ UI.info(RUST_REGEX_GUIDANCE)
33
+ puts
34
+ trigger = prompt_non_empty_trigger('r"^(hello|bye)$"')
35
+ return [[trigger], true]
36
+ end
37
+
38
+ [prompt_trigger_loop, false]
39
+ end
40
+
41
+ def prompt_non_empty_trigger(placeholder, header: nil)
42
+ prompt_non_empty('Trigger cannot be empty. Please enter a trigger string.') do
43
+ prompt!(Gum.input(**trigger_input_opts(placeholder, header)))
44
+ end
45
+ end
46
+
47
+ def multi_trigger_header
48
+ "Multiple triggers can share one replacement.\nEnter them one at a time.\n"
49
+ end
50
+
51
+ def trigger_input_opts(placeholder, header)
52
+ opts = { placeholder: placeholder, prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE }
53
+ opts[:header] = header if header
54
+ opts
55
+ end
56
+
57
+ def prompt_trigger_loop
58
+ triggers = []
59
+ loop do
60
+ header = triggers.empty? ? multi_trigger_header : nil
61
+ triggers << prompt_non_empty_trigger(':trigger', header: header)
62
+ break unless list_confirm!('trigger', triggers.map { |t| [t] }, ['Trigger'], 'Add another trigger?')
63
+ end
64
+ triggers
65
+ end
66
+ end
67
+ end