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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Carries shared wizard configuration: which file to save to,
5
+ # which global var names are already declared in that file,
6
+ # and the pipe IO for structured output when stdout is redirected.
7
+ # Passed explicitly rather than communicated via global state.
8
+ WizardContext = Data.define(:global_var_names, :save_path, :pipe_output) do
9
+ def initialize(global_var_names: [], save_path: nil, pipe_output: nil)
10
+ super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ module WizardHelpers
5
+ # Wraps a command body with standard error handling.
6
+ # Rescues WizardInterrupted (Ctrl+C) universally.
7
+ # Rescues typed error_classes passed by the caller, displaying their message via UI.error and exiting 1.
8
+ module ErrorHandler
9
+ def handle_errors(*error_classes)
10
+ yield
11
+ rescue *error_classes => e
12
+ UI.error(e.message)
13
+ exit 1
14
+ rescue WizardInterrupted
15
+ puts
16
+ UI.error('Interrupted, exiting snippet_cli.')
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gum'
4
+ require_relative 'prompt_helpers'
5
+
6
+ module SnippetCli
7
+ module WizardHelpers
8
+ # Selects an Espanso match file for saving.
9
+ # Auto-selects when only one file exists; otherwise prompts via Gum.filter.
10
+ module MatchFileSelector
11
+ include PromptHelpers
12
+
13
+ # Returns [basename, full_path] of the chosen match file.
14
+ # Raises NoMatchFilesError when no files exist.
15
+ def pick_match_file
16
+ files = EspansoConfig.match_files
17
+ abort_no_match_files if files.empty?
18
+ return [File.basename(files.first), files.first] if files.size == 1
19
+
20
+ basenames = files.map { |f| File.basename(f) }
21
+ chosen = prompt!(Gum.filter(*basenames, header: 'Save to which match file?'))
22
+ [chosen, files.find { |f| File.basename(f) == chosen }]
23
+ end
24
+
25
+ private
26
+
27
+ def abort_no_match_files
28
+ raise NoMatchFilesError, 'No match files found in Espanso config.'
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'gum'
5
+ require_relative '../table_formatter'
6
+
7
+ module SnippetCli
8
+ module WizardHelpers
9
+ # Gum prompt primitives with Ctrl+C detection via WizardInterrupted.
10
+ module PromptHelpers
11
+ # Returns the value if non-nil; raises WizardInterrupted otherwise.
12
+ # Gum.choose / .input / .filter / .write return nil on Ctrl+C.
13
+ def prompt!(value)
14
+ raise WizardInterrupted if value.nil?
15
+
16
+ value
17
+ rescue Interrupt
18
+ raise WizardInterrupted
19
+ end
20
+
21
+ # Wraps Gum.confirm and checks $?.exitstatus for 130 (Ctrl+C).
22
+ # Gum.confirm uses system() which swallows SIGINT and returns false,
23
+ # making it indistinguishable from the user answering "no" — except
24
+ # that $? records the child's exit code 130.
25
+ # SIGINT can also raise Interrupt in Ruby before $? is read.
26
+ def confirm!(text)
27
+ result = Gum.confirm(text, prompt_style: UI::PROMPT_STYLE)
28
+ raise WizardInterrupted if result.nil?
29
+ raise WizardInterrupted if $CHILD_STATUS.respond_to?(:exitstatus) && $CHILD_STATUS.exitstatus == 130
30
+
31
+ result
32
+ rescue Interrupt
33
+ raise WizardInterrupted
34
+ end
35
+
36
+ # Renders a table of collected items and asks a follow-up question, without a border.
37
+ def list_confirm!(label, rows, headers, question)
38
+ table = TableFormatter.render(rows, headers: headers)
39
+ confirm!("Current #{label}s:\n\n#{table}\n\n#{question}")
40
+ end
41
+
42
+ # Confirms a question then collects a value via the block, or returns nil if declined.
43
+ def optional_prompt(question)
44
+ yield if confirm!(question)
45
+ end
46
+
47
+ # Prompts for search terms via a multiline write block.
48
+ # Returns an empty array if the user declines.
49
+ def collect_search_terms
50
+ return [] unless confirm!('Add search terms?')
51
+
52
+ raw = prompt!(Gum.write(header: 'Put one search term per line', prompt_style: UI::PROMPT_STYLE, header_style: UI::PROMPT_STYLE))
53
+ raw.to_s.lines.map(&:chomp).reject(&:empty?)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ module WizardHelpers
5
+ # Loop-until-valid prompt abstractions.
6
+ module ValidationLoop
7
+ # General loop-until-valid primitive.
8
+ # The block must yield [value, error_or_nil].
9
+ # When error is a String, shows it as a transient warning.
10
+ # When error is a Callable (e.g. a lambda), uses it directly as the clear function.
11
+ # Loops until the block yields a nil error.
12
+ def prompt_until_valid
13
+ clear = nil
14
+ loop do
15
+ value, error = yield
16
+ clear&.call
17
+ return value if error.nil?
18
+
19
+ clear = error.respond_to?(:call) ? error : UI.transient_warning(error)
20
+ end
21
+ end
22
+
23
+ # Loops until the block yields a non-empty string.
24
+ # Shows warning_message as a transient warning on empty input.
25
+ def prompt_non_empty(warning_message, &prompt_block)
26
+ prompt_until_valid do
27
+ value = prompt_block.call
28
+ [value, value.strip.empty? ? warning_message : nil]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience require — loads all focused WizardHelpers sub-modules.
4
+ # Prefer requiring only the specific sub-module your class needs.
5
+ require_relative 'wizard_helpers/prompt_helpers'
6
+ require_relative 'wizard_helpers/validation_loop'
7
+ require_relative 'wizard_helpers/match_file_selector'
8
+ require_relative 'wizard_helpers/error_handler'
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+
5
+ module SnippetCli
6
+ # Resolves a JSON Pointer (e.g. "/matches/354") to a 1-based line number
7
+ # in a YAML file by navigating the Psych AST node tree.
8
+ module YamlLineResolver
9
+ # Returns the 1-based line number for the given JSON pointer, or nil if
10
+ # the pointer is empty, invalid, or cannot be resolved in the file.
11
+ def self.resolve(file, pointer)
12
+ return nil if pointer.to_s.empty?
13
+
14
+ segments = pointer.to_s.sub(%r{\A/}, '').split('/')
15
+ return nil if segments.empty?
16
+
17
+ tree = Psych.parse_file(file)
18
+ node = navigate(tree.root, segments)
19
+ node&.start_line&.+(1)
20
+ rescue StandardError
21
+ nil
22
+ end
23
+
24
+ def self.navigate(node, segments)
25
+ return node if segments.empty?
26
+
27
+ seg, *rest = segments
28
+ case node
29
+ when Psych::Nodes::Document then navigate(node.root, segments)
30
+ when Psych::Nodes::Mapping then navigate_mapping(node, seg, rest)
31
+ when Psych::Nodes::Sequence then navigate(node.children[Integer(seg, 10)], rest)
32
+ end
33
+ rescue ArgumentError, TypeError
34
+ nil
35
+ end
36
+ private_class_method :navigate
37
+
38
+ def self.navigate_mapping(node, key, rest)
39
+ node.children.each_slice(2) do |k, v|
40
+ return navigate(v, rest) if k.value == key
41
+ end
42
+ nil
43
+ end
44
+ private_class_method :navigate_mapping
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'file_helper'
5
+
6
+ module SnippetCli
7
+ # Shared YAML file loading with existence check and syntax-error handling.
8
+ module YamlLoader
9
+ # Loads and parses a YAML file.
10
+ # Raises FileMissingError if the file does not exist.
11
+ # Raises InvalidYamlError if the file contains invalid YAML syntax.
12
+ def self.load(path, permitted_classes: [Symbol])
13
+ FileHelper.ensure_readable!(path)
14
+ YAML.safe_load_file(path, permitted_classes: permitted_classes) || {}
15
+ rescue Psych::SyntaxError => e
16
+ raise InvalidYamlError, "Invalid YAML: #{e.message}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'yaml_scalar'
4
+
5
+ module SnippetCli
6
+ # Renders variable param key/value pairs as YAML lines with proper indentation.
7
+ module YamlParamRenderer
8
+ def self.lines(key, val, indent)
9
+ case val
10
+ when Hash then hash_lines(key, val, indent)
11
+ when Array then ["#{indent}#{key}:", *val.map { |item| "#{indent} - #{YamlScalar.quote(item.to_s)}" }]
12
+ when true, false then ["#{indent}#{key}: #{val}"]
13
+ else scalar_lines(key, val.to_s, indent)
14
+ end
15
+ end
16
+
17
+ def self.hash_lines(key, hash, indent)
18
+ result = ["#{indent}#{key}:"]
19
+ hash.each { |k, v| result.concat(lines(k, v, "#{indent} ")) }
20
+ result
21
+ end
22
+ private_class_method :hash_lines
23
+
24
+ def self.scalar_lines(key, str, indent)
25
+ if str.include?("\n")
26
+ indented = str.lines.map { |line| "#{indent} #{line.chomp}" }.join("\n")
27
+ ["#{indent}#{key}: |", indented]
28
+ else
29
+ ["#{indent}#{key}: #{YamlScalar.quote(str)}"]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Helpers for quoting scalar values in hand-built YAML output.
5
+ module YamlScalar
6
+ class InvalidCharacterError < StandardError; end
7
+
8
+ BOOLEAN_LIKE = /\A(y|n|yes|no|true|false|on|off|null|~)\z/i
9
+ LEADING_SPECIAL = /\A[:#&*!|>"%@`{}\[\]]/
10
+ # Control characters that are invalid in YAML scalars (excludes tab \x09, newline \x0a, carriage return \x0d)
11
+ CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
12
+
13
+ # Quote a scalar value for YAML output.
14
+ # Strategy (per yaml-multiline.info):
15
+ # - string containing ' → normal-quoted with escaped inner content
16
+ # - string matching special YAML patterns → normal-quoted
17
+ # - all other strings → single-quoted
18
+ def self.quote(str)
19
+ return "''" if str.nil? || str.empty?
20
+
21
+ reject_control_chars!(str)
22
+
23
+ if str.include?("'")
24
+ escaped = str.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
25
+ return "\"#{escaped}\""
26
+ end
27
+
28
+ return "\"#{str.gsub('\\', '\\\\\\\\').gsub('"', '\\"')}\"" if needs_normal_quote?(str)
29
+
30
+ "'#{str}'"
31
+ end
32
+
33
+ def self.reject_control_chars!(str)
34
+ return unless CONTROL_CHARS.match?(str)
35
+
36
+ raise InvalidCharacterError, "String contains YAML-invalid control characters: #{str.inspect}"
37
+ end
38
+ private_class_method :reject_control_chars!
39
+
40
+ def self.needs_normal_quote?(str)
41
+ LEADING_SPECIAL.match?(str) ||
42
+ BOOLEAN_LIKE.match?(str) ||
43
+ str.include?(': ') ||
44
+ str.include?(' #')
45
+ end
46
+ private_class_method :needs_normal_quote?
47
+ end
48
+ end
data/lib/snippet_cli.rb CHANGED
@@ -1,4 +1,47 @@
1
- require "snippet_cli/version"
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require_relative 'snippet_cli/version'
5
+ require_relative 'snippet_cli/commands/version'
6
+ require_relative 'snippet_cli/commands/conflict'
7
+ require_relative 'snippet_cli/commands/vars'
8
+ require_relative 'snippet_cli/commands/new'
9
+ require_relative 'snippet_cli/commands/check'
2
10
 
3
11
  module SnippetCli
12
+ # Raised when any Gum prompt is cancelled by Ctrl+C.
13
+ class WizardInterrupted < StandardError; end
14
+
15
+ # Raised by FileHelper when a required file does not exist.
16
+ class FileMissingError < StandardError; end
17
+
18
+ # Raised by YamlLoader when a file contains invalid YAML syntax.
19
+ class InvalidYamlError < StandardError; end
20
+
21
+ # Raised by TriggerResolver when mutually exclusive trigger flags are combined.
22
+ class InvalidFlagsError < StandardError; end
23
+
24
+ # Raised by WizardHelpers when no Espanso match files are found.
25
+ class NoMatchFilesError < StandardError; end
26
+
27
+ # Raised by VarBuilder::Params when collected params violate the type's schema.
28
+ class InvalidParamsError < StandardError; end
29
+
30
+ # When stdout is piped, holds the original stdout IO for structured output (YAML).
31
+ # All UI continues through $stdout (redirected to the terminal).
32
+ @pipe_output = nil
33
+
34
+ class << self
35
+ attr_accessor :pipe_output
36
+ end
37
+
38
+ module CLI
39
+ extend Dry::CLI::Registry
40
+
41
+ register 'version', Commands::Version
42
+ register 'conflict', Commands::Conflict, aliases: ['c']
43
+ register 'vars', Commands::Vars, aliases: ['v']
44
+ register 'new', Commands::New, aliases: ['n']
45
+ register 'check', Commands::Check, aliases: ['k']
46
+ end
4
47
  end