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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gum'
4
+
5
+ module SnippetCli
6
+ FIGLET_ART = "┏━┓┏┓╻╻┏━┓┏━┓┏━╸╺┳╸ ┏━╸╻ ╻\n" \
7
+ "┗━┓┃┗┫┃┣━┛┣━┛┣╸ ┃ ┃ ┃ ┃\n" \
8
+ '┗━┛╹ ╹╹╹ ╹ ┗━╸ ╹ ┗━╸┗━╸╹'
9
+
10
+ def self.banner
11
+ Gum::Command.run_non_interactive(
12
+ 'style', '--border=rounded', '--padding=1 2', '--align=center', '--border-foreground=075',
13
+ input: FIGLET_ART
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require_relative '../file_validator'
5
+ require_relative '../ui'
6
+ require_relative '../yaml_loader'
7
+ require_relative '../yaml_line_resolver'
8
+ require_relative '../table_formatter'
9
+ require_relative '../wizard_helpers/error_handler'
10
+ require_relative '../wizard_helpers/match_file_selector'
11
+ require_relative '../espanso_config'
12
+
13
+ module SnippetCli
14
+ module Commands
15
+ class Check < Dry::CLI::Command
16
+ include WizardHelpers::ErrorHandler
17
+ include WizardHelpers::MatchFileSelector
18
+
19
+ desc 'Check a match file against the Espanso schema (alias: k)'
20
+
21
+ option :file, aliases: ['-f'], desc: 'Path to the Espanso match YAML file to check'
22
+
23
+ def call(file: nil, **)
24
+ handle_errors(NoMatchFilesError) do
25
+ file ||= pick_match_file.last
26
+ data = YamlLoader.load(file)
27
+ report(file, FileValidator.errors_structured(data))
28
+ end
29
+ rescue FileMissingError, InvalidYamlError => e
30
+ warn e.message
31
+ exit 1
32
+ end
33
+
34
+ private
35
+
36
+ def report(file, errors)
37
+ return UI.success("#{file} is valid.") if errors.empty?
38
+
39
+ rows = errors.map do |e|
40
+ line = YamlLineResolver.resolve(file, e[:pointer])
41
+ [line || '?', e[:pointer].empty? ? '(root)' : e[:pointer], e[:message]]
42
+ end
43
+ warn "\e[38;5;231mValidation errors found:\e[0m"
44
+ warn TableFormatter.render(rows, headers: %w[Line Location Error])
45
+ exit 1
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'gum'
5
+ require 'yaml'
6
+ require_relative '../conflict_detector'
7
+ require_relative '../ui'
8
+ require_relative '../wizard_helpers/error_handler'
9
+ require_relative '../wizard_helpers/match_file_selector'
10
+ require_relative '../file_helper'
11
+ require_relative '../espanso_config'
12
+
13
+ module SnippetCli
14
+ module Commands
15
+ class Conflict < Dry::CLI::Command
16
+ include WizardHelpers::ErrorHandler
17
+ include WizardHelpers::MatchFileSelector
18
+
19
+ desc 'Detect duplicate triggers in a match file (alias: c)'
20
+
21
+ option :file, aliases: ['-f'], desc: 'Path to Espanso match YAML file'
22
+ option :trigger, type: :array, aliases: ['-t'], desc: 'Trigger(s) to look up (comma-separated or repeated flag)'
23
+
24
+ def call(file: nil, trigger: nil, **)
25
+ handle_errors(NoMatchFilesError) { detect_conflicts(file, trigger) }
26
+ rescue FileMissingError => e
27
+ warn e.message
28
+ exit 1
29
+ rescue Psych::SyntaxError => e
30
+ warn "Invalid YAML: #{e.message}"
31
+ exit 1
32
+ end
33
+
34
+ private
35
+
36
+ def detect_conflicts(file, trigger)
37
+ file ||= pick_match_file.last
38
+ FileHelper.ensure_readable!(file)
39
+ entries = load_entries(file)
40
+ trigger ? show_trigger(entries, trigger) : show_conflicts(entries)
41
+ end
42
+
43
+ def load_entries(file)
44
+ ConflictDetector.extract_triggers(File.read(file))
45
+ end
46
+
47
+ def show_conflicts(entries)
48
+ duplicates = entries.group_by { |e| e[:trigger] }.select { |_, v| v.size > 1 }
49
+ if duplicates.empty?
50
+ puts 'No conflicts found'
51
+ return
52
+ end
53
+ UI.note('The following conflicts were found:')
54
+ Gum.table(build_rows(duplicates), columns: %w[Trigger Lines], separator: "\t", print: true)
55
+ end
56
+
57
+ def build_rows(groups)
58
+ groups.map do |trig, occurrences|
59
+ [trig, occurrences.map { |e| e[:line] }.join(', ')]
60
+ end
61
+ end
62
+
63
+ def show_trigger(entries, triggers)
64
+ matches = entries.select { |e| triggers.include?(e[:trigger]) }
65
+ if matches.empty?
66
+ puts "Trigger(s) #{triggers.join(', ')} not found"
67
+ return
68
+ end
69
+ UI.note('The following conflicts were found:')
70
+ rows = build_rows(matches.group_by { |e| e[:trigger] })
71
+ Gum.table(rows, columns: %w[Trigger Lines], separator: "\t", print: true)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,112 +1,26 @@
1
1
  # frozen_string_literal: true
2
- require 'bundler/setup'
3
- require 'tty-box'
4
- require 'tty-prompt'
5
- require_relative '../../snippet_generator'
6
- require 'httparty'
7
- require 'json'
8
- require 'ascii'
9
- # require 'snippets_for_espanso/SnippetGenerator'
10
- require_relative '../command'
11
2
 
3
+ require 'dry/cli'
4
+ require_relative '../new_workflow'
12
5
 
13
6
  module SnippetCli
14
7
  module Commands
15
- class New < SnippetCli::Command
16
- def show_banner()
17
- box = TTY::Box::frame(width:67, height:11, border: :thick, align: :left) do
18
- "
19
- ##### # # ### ###### ###### ####### #######
20
- # # ## # # # # # # # #
21
- # # # # # # # # # # #
22
- ##### # # # # ###### ###### ##### #
23
- # # # # # # # # #
24
- # # # ## # # # # #
25
- ##### # # ### # # ####### # CLI
26
- "
27
- end
28
- puts box
29
- end
30
- include SnippetGenerator
31
- @leading = " "
32
-
33
- prompt=TTY::Prompt.new
34
- def initialize(options)
35
- @options = options
36
- @file_path = File.readlines("#{ENV["HOME"]}/snippet_cli_config.txt")[1]
37
- @file_path = Ascii.process(@file_path)
38
- end
39
-
40
- def but_first()
41
- puts @leading
42
- puts "Now you'll enter what you want replaced."
43
- puts @leading
44
- puts "But first ..."
45
- puts @leading
46
- prompt.error("Don't use tabs. YAML hates them and it leads to unpredictable results.")
47
- puts @leading
48
- end
49
-
50
- def new_form()
51
- puts "Let's add a new snippet to your configuration"
52
- puts @leading
53
- snippet_type = prompt.select("Do you want a Snippet or a Snippet with a form?") do |menu|
54
- menu.enum "."
8
+ class New < Dry::CLI::Command
9
+ desc 'Build an Espanso match entry interactively (alias: n)'
55
10
 
56
- menu.choice "A snippet",1
57
- menu.choice "A snippet with a form",2
58
- menu.choice "A snippet from Semplificato API",3
59
- end
60
- case snippet_type
61
- when 1
62
- puts @leading
63
- snippet_trigger=prompt.ask("What do you want to type to trigger the snippet?")
64
- puts @leading
65
- puts "Okay, the snippet will be triggered by:"
66
- prompt.ok( ":#{snippet_trigger}")
67
- puts@leading
68
- but_first()
69
- replacement = prompt.multiline("what did you want the trigger to be replaced with?")
70
- if (replacement.length() > 1)
71
- single_snippet_export(@file_path,snippet_trigger,replacement)
72
- else
73
- single_snippet_export(@file_path,snippet_trigger,replacement[0])
74
- end
75
- when 2
76
- puts @leading
77
- snippet_trigger=prompt.ask("What do you want to type to trigger the snippet?")
78
- puts @leading
79
- puts "Okay, the snippet will be triggered by:"
80
- prompt.ok( ":#{snippet_trigger}")
81
- puts@leading
82
- but_first()
83
- newprompt = TTY::Prompt.new
84
- newprompt.warn("For a form field wrap the word in double brackets. Like {{example}}")
85
- puts @leading
86
- newprompt.ok("Also make sure the name of each form field is unique.")
87
- puts @leading
88
- replacement = prompt.multiline("what did you want the trigger to be replaced with?")
89
- if (replacement.length() > 1)
90
- input_form_snippet_export(@file_path,snippet_trigger,replacement)
91
- else
92
- input_form_snippet_export(@file_path,snippet_trigger,replacement[0])
93
- end
94
- when 3
95
- puts @leading
96
- url = prompt.ask("What's the URL of the snippet?",default: "http://localhost:3000/snippets/1")
97
- json_url = url+(".json")
98
- api_response=HTTParty.get(json_url)
99
- response_parsed = api_response.body
100
- single_snippet_export(@file_path,response_parsed['trigger'],response_parsed['replacement'])
101
- puts@leading
102
- prompt.ok("Added snippet from #{url}")
103
- end
104
- end
11
+ option :save, type: :flag, default: false, aliases: ['-s'],
12
+ desc: 'Save snippet to Espanso match file'
13
+ option :no_vars, type: :flag, default: false, aliases: ['-n'],
14
+ desc: 'Skip variables; supports alt types (image_path, markdown, html) and advanced options'
15
+ option :bare, type: :flag, default: false, aliases: ['-b'],
16
+ desc: 'Trigger(s) and plaintext only (single/multi-line); no vars, alt types, or advanced'
105
17
 
106
- def execute(input: $stdin, output: $stdout)
107
- # Command logic goes here ...
108
- output.puts show_banner()
109
- new_form()
18
+ def call(**opts)
19
+ if opts[:bare] && opts[:no_vars]
20
+ warn '--bare and --no-vars are mutually exclusive. Provide only one.'
21
+ exit 1
22
+ end
23
+ NewWorkflow.new.run(opts)
110
24
  end
111
25
  end
112
26
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require_relative '../var_builder'
5
+ require_relative '../vars_block_renderer'
6
+ require_relative '../snippet_builder'
7
+ require_relative '../ui'
8
+ require_relative '../wizard_context'
9
+ require_relative '../wizard_helpers/error_handler'
10
+ require_relative '../wizard_helpers/match_file_selector'
11
+ require_relative '../espanso_config'
12
+ require_relative '../global_vars_writer'
13
+
14
+ module SnippetCli
15
+ module Commands
16
+ class Vars < Dry::CLI::Command
17
+ include WizardHelpers::ErrorHandler
18
+ include WizardHelpers::MatchFileSelector
19
+
20
+ desc 'Build an Espanso vars block interactively (alias: v)'
21
+
22
+ option :save, type: :flag, default: false, aliases: ['-s'],
23
+ desc: 'Save vars to Espanso match file under global_vars'
24
+
25
+ def call(**opts)
26
+ handle_errors(EspansoConfigError, NoMatchFilesError) do
27
+ context = WizardContext.new(pipe_output: SnippetCli.pipe_output)
28
+ result = VarBuilder.run(skip_initial_prompt: true)
29
+ save_vars(result[:vars]) if opts[:save]
30
+ deliver_vars(result[:vars], context)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def deliver_vars(vars, context)
37
+ UI.deliver(vars_yaml(vars), label: 'Vars', context: context)
38
+ end
39
+
40
+ def save_vars(vars)
41
+ return if vars.empty?
42
+
43
+ chosen, full_path = pick_match_file
44
+ entries = vars_yaml(vars).sub(/\Avars:\n/, '')
45
+ GlobalVarsWriter.append(full_path, entries)
46
+ UI.success("Saved to #{chosen}")
47
+ end
48
+
49
+ # pick_match_file is provided by WizardHelpers
50
+
51
+ def vars_yaml(vars)
52
+ return "vars: []\n" if vars.empty?
53
+
54
+ "#{VarsBlockRenderer.render(vars).join("\n")}\n"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require_relative '../version'
5
+ require_relative '../ui'
6
+
7
+ module SnippetCli
8
+ module Commands
9
+ class Version < Dry::CLI::Command
10
+ desc 'Print the snippet_cli version'
11
+
12
+ def call(**)
13
+ UI.info("snippet_cli v#{SnippetCli::VERSION}")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+
5
+ module SnippetCli
6
+ module ConflictDetector
7
+ TRIGGER_KEYS = %w[trigger triggers regex].freeze
8
+
9
+ def self.extract_triggers(content)
10
+ return [] if content.nil? || content.strip.empty?
11
+
12
+ doc = Psych.parse(content)
13
+ return [] unless doc
14
+
15
+ root = doc.root
16
+ return [] unless root.is_a?(Psych::Nodes::Mapping)
17
+
18
+ matches_seq = find_matches_sequence(root)
19
+ return [] unless matches_seq
20
+
21
+ extract_from_sequence(matches_seq)
22
+ end
23
+
24
+ def self.find_matches_sequence(mapping)
25
+ mapping.children.each_slice(2) do |key, value|
26
+ return value if key.is_a?(Psych::Nodes::Scalar) && key.value == 'matches'
27
+ end
28
+ nil
29
+ end
30
+ private_class_method :find_matches_sequence
31
+
32
+ def self.extract_from_sequence(sequence)
33
+ return [] unless sequence.is_a?(Psych::Nodes::Sequence)
34
+
35
+ sequence.children.each_with_object([]) do |match_node, entries|
36
+ next unless match_node.is_a?(Psych::Nodes::Mapping)
37
+
38
+ extract_from_mapping(match_node, entries)
39
+ end
40
+ end
41
+ private_class_method :extract_from_sequence
42
+
43
+ def self.extract_from_mapping(mapping, entries)
44
+ mapping.children.each_slice(2) do |key, value|
45
+ next unless key.is_a?(Psych::Nodes::Scalar)
46
+
47
+ extract_key_value(key.value, value, entries)
48
+ end
49
+ end
50
+ private_class_method :extract_from_mapping
51
+
52
+ def self.extract_key_value(key, value, entries)
53
+ case key
54
+ when 'trigger', 'regex'
55
+ entries << { trigger: value.value, line: value.start_line + 1 } if value.is_a?(Psych::Nodes::Scalar)
56
+ when 'triggers'
57
+ extract_triggers_array(value, entries)
58
+ end
59
+ end
60
+ private_class_method :extract_key_value
61
+
62
+ def self.extract_triggers_array(sequence, entries)
63
+ return unless sequence.is_a?(Psych::Nodes::Sequence)
64
+
65
+ sequence.children.each do |scalar|
66
+ entries << { trigger: scalar.value, line: scalar.start_line + 1 } if scalar.is_a?(Psych::Nodes::Scalar)
67
+ end
68
+ end
69
+ private_class_method :extract_triggers_array
70
+ end
71
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-cursor'
4
+
5
+ module SnippetCli
6
+ # Utility for TTY cursor manipulation.
7
+ module CursorHelper
8
+ # Returns a lambda that erases `line_count` lines upward when called.
9
+ # Returns a no-op lambda when stdout is not a TTY.
10
+ def self.build_erase_lambda(line_count)
11
+ return -> {} unless $stdout.tty?
12
+
13
+ lambda {
14
+ $stdout.print TTY::Cursor.up(line_count)
15
+ $stdout.print "\r"
16
+ $stdout.print TTY::Cursor.clear_screen_down
17
+ }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module SnippetCli
6
+ class EspansoConfigError < StandardError; end
7
+
8
+ # Discovers Espanso config paths by shelling out to `espanso path`.
9
+ module EspansoConfig
10
+ # Returns the match directory path (e.g. ~/.config/espanso/match).
11
+ def self.match_dir
12
+ output, status = Open3.capture2('espanso', 'path')
13
+ raise EspansoConfigError, 'Could not determine Espanso config path. Is espanso installed?' unless status.success?
14
+
15
+ config_line = output.lines.find { |l| l.start_with?('Config:') }
16
+ raise EspansoConfigError, 'Could not determine Espanso config path from `espanso path` output.' unless config_line
17
+
18
+ File.join(config_line.split(':', 2).last.strip, 'match')
19
+ end
20
+
21
+ # Returns sorted list of .yml files in the match directory.
22
+ def self.match_files
23
+ Dir.glob(File.join(match_dir, '**', '*.yml'), sort: true)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Utility methods for safe file reading.
5
+ module FileHelper
6
+ # Checks that path exists. Raises FileMissingError if not.
7
+ def self.ensure_readable!(path)
8
+ raise FileMissingError, "File not found: #{path}" unless File.exist?(path)
9
+ end
10
+
11
+ # Returns the contents of path if it exists, or an empty string otherwise.
12
+ def self.read_or_empty(path)
13
+ File.exist?(path) ? File.read(path) : ''
14
+ end
15
+ end
16
+ 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 a full Espanso match file (matches array + global_vars + imports + anchors)
8
+ # against the vendored merged schema (official + custom extensions).
9
+ module FileValidator
10
+ # Returns true if the data hash is valid against the matchfile schema.
11
+ def self.valid?(data)
12
+ SchemaValidator.valid?(HashUtils.stringify_keys_deep(data))
13
+ end
14
+
15
+ # Returns an array of human-readable error strings with field pointers.
16
+ # Empty array means the data is valid.
17
+ def self.errors(data)
18
+ errors_structured(data).map do |e|
19
+ e[:pointer].empty? ? e[:message] : "at #{e[:pointer]}: #{e[:message]}"
20
+ end
21
+ end
22
+
23
+ # Returns an array of structured error hashes: { pointer: String, message: String }.
24
+ # Empty array means the data is valid.
25
+ def self.errors_structured(data)
26
+ SchemaValidator.validate(HashUtils.stringify_keys_deep(data)).map do |error|
27
+ pointer = error['data_pointer'].to_s
28
+ message = error['error'] || error.fetch('type', 'validation error')
29
+ { pointer: pointer, message: message }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Shared file-writing interface for all Espanso file writers.
5
+ # Centralises the write operation so file-safety behaviors (e.g. atomic write)
6
+ # need only be added here.
7
+ module FileWriter
8
+ def self.write(path, content)
9
+ File.write(path, content)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Parses [[field_name]] placeholders from form variable layout strings.
5
+ module FormFieldParser
6
+ PATTERN = /\[\[\s*(\w+)\s*\]\]/
7
+
8
+ # Returns an array of field name strings extracted from the layout.
9
+ def self.extract(layout)
10
+ layout.to_s.scan(PATTERN).flatten
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'string_helper'
4
+
5
+ module SnippetCli
6
+ # Pure formatting logic for the global_vars section of an Espanso match file.
7
+ # Operates entirely on strings — no file I/O.
8
+ module GlobalVarsFormatter
9
+ # Returns updated file content with var_entries appended under global_vars.
10
+ # Creates the global_vars key if absent. Never overwrites existing vars.
11
+ # +existing+ is the current file content (or empty string).
12
+ # +var_entries+ is the pre-indented YAML string for the new vars.
13
+ def self.build_content(existing, var_entries)
14
+ return new_global_vars(var_entries) if existing.strip.empty?
15
+ return insert_into_block(existing, var_entries) if existing.match?(/^global_vars:\s*$/m)
16
+
17
+ append_global_vars(existing, var_entries)
18
+ end
19
+
20
+ def self.new_global_vars(var_entries)
21
+ StringHelper.ensure_trailing_newline("global_vars:\n#{var_entries}")
22
+ end
23
+ private_class_method :new_global_vars
24
+
25
+ def self.append_global_vars(existing, var_entries)
26
+ StringHelper.ensure_trailing_newline("#{existing.chomp}\n\nglobal_vars:\n#{var_entries}")
27
+ end
28
+ private_class_method :append_global_vars
29
+
30
+ def self.insert_into_block(existing, var_entries)
31
+ lines = existing.lines
32
+ gv_index = lines.index { |l| l.match?(/^global_vars:\s*$/) }
33
+ last_content = find_block_end(lines, gv_index)
34
+
35
+ join_parts(lines, last_content, var_entries)
36
+ end
37
+ private_class_method :insert_into_block
38
+
39
+ def self.find_block_end(lines, gv_index)
40
+ last_content = gv_index
41
+ ((gv_index + 1)...lines.length).each do |i|
42
+ break if lines[i].match?(/^\S/)
43
+
44
+ last_content = i unless lines[i].strip.empty?
45
+ end
46
+ last_content
47
+ end
48
+ private_class_method :find_block_end
49
+
50
+ def self.join_parts(lines, last_content, var_entries)
51
+ before = lines[0..last_content].join
52
+ rest = lines[(last_content + 1)..]
53
+
54
+ result = "#{before.chomp}\n#{var_entries}"
55
+ if rest && !rest.empty?
56
+ result << "\n" unless result.end_with?("\n\n") || rest.first&.strip&.empty?
57
+ result << rest.join
58
+ end
59
+ StringHelper.ensure_trailing_newline(result)
60
+ end
61
+ private_class_method :join_parts
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'file_helper'
5
+ require_relative 'file_writer'
6
+ require_relative 'global_vars_formatter'
7
+
8
+ module SnippetCli
9
+ # Thin I/O wrapper around GlobalVarsFormatter.
10
+ # Reads from and writes to Espanso match files; delegates all formatting logic.
11
+ module GlobalVarsWriter
12
+ # Appends var entries under the global_vars key in the given file.
13
+ # Creates the key if it doesn't exist. Never overwrites existing vars.
14
+ # +var_entries+ is the indented YAML string (each line already indented by 2).
15
+ def self.append(file_path, var_entries)
16
+ existing = FileHelper.read_or_empty(file_path)
17
+ content = GlobalVarsFormatter.build_content(existing, var_entries)
18
+ FileWriter.write(file_path, content)
19
+ end
20
+
21
+ # Returns an array of var name strings from the global_vars key in the file.
22
+ def self.read_names(file_path)
23
+ return [] unless File.exist?(file_path)
24
+
25
+ data = YAML.safe_load_file(file_path, permitted_classes: [Symbol]) || {}
26
+ Array(data['global_vars']).filter_map { |v| v['name'] }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnippetCli
4
+ # Applies Gum color overrides via environment variables.
5
+ module GumTheme
6
+ COLORS = {
7
+ # gum confirm
8
+ 'GUM_CONFIRM_PROMPT_FOREGROUND' => '231',
9
+ 'GUM_CONFIRM_SELECTED_FOREGROUND' => '#F2D07C',
10
+ 'GUM_CONFIRM_SELECTED_BACKGROUND' => '#6B7A90',
11
+
12
+ # gum choose — replaces default purple cursor (used for trigger type)
13
+ 'GUM_CHOOSE_CURSOR_FOREGROUND' => '#8CAAED',
14
+ 'GUM_CHOOSE_SELECTED_FOREGROUND' => '#A5D18A',
15
+ 'GUM_CHOOSE_HEADER_FOREGROUND' => '231',
16
+
17
+ # gum filter — replaces default purple indicator and pink match highlight
18
+ 'GUM_FILTER_INDICATOR_FOREGROUND' => '#8CAAED',
19
+ 'GUM_FILTER_MATCH_FOREGROUND' => '#E88284',
20
+ 'GUM_FILTER_SELECTED_FOREGROUND' => '#A5D18A',
21
+ 'GUM_FILTER_PROMPT_FOREGROUND' => '#8CAAED',
22
+ 'GUM_FILTER_HEADER_FOREGROUND' => '231',
23
+
24
+ # gum input — replaces default purple cursor
25
+ 'GUM_INPUT_CURSOR_FOREGROUND' => '#8CAAED',
26
+ 'GUM_INPUT_PROMPT_FOREGROUND' => '#8CAAED',
27
+ 'GUM_INPUT_HEADER_FOREGROUND' => '231',
28
+
29
+ # gum write — replaces default purple cursor
30
+ 'GUM_WRITE_CURSOR_FOREGROUND' => '#8CAAED',
31
+ 'GUM_WRITE_PROMPT_FOREGROUND' => '#8CAAED',
32
+ 'GUM_WRITE_HEADER_FOREGROUND' => '231'
33
+ }.freeze
34
+
35
+ def self.apply!
36
+ COLORS.each { |key, val| ENV[key] = val }
37
+ end
38
+ end
39
+ end