snippet_cli 0.3.6 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +29 -15
- data/exe/snippet_cli +45 -11
- data/lib/snippet_cli/Espanso_Merged_Matchfile_Schema.json +889 -0
- data/lib/snippet_cli/banner.rb +16 -0
- data/lib/snippet_cli/commands/check.rb +49 -0
- data/lib/snippet_cli/commands/conflict.rb +75 -0
- data/lib/snippet_cli/commands/new.rb +16 -102
- data/lib/snippet_cli/commands/vars.rb +58 -0
- data/lib/snippet_cli/commands/version.rb +17 -0
- data/lib/snippet_cli/conflict_detector.rb +71 -0
- data/lib/snippet_cli/cursor_helper.rb +20 -0
- data/lib/snippet_cli/espanso_config.rb +26 -0
- data/lib/snippet_cli/file_helper.rb +16 -0
- data/lib/snippet_cli/file_validator.rb +33 -0
- data/lib/snippet_cli/file_writer.rb +12 -0
- data/lib/snippet_cli/form_field_parser.rb +13 -0
- data/lib/snippet_cli/global_vars_formatter.rb +63 -0
- data/lib/snippet_cli/global_vars_writer.rb +29 -0
- data/lib/snippet_cli/gum_theme.rb +39 -0
- data/lib/snippet_cli/hash_utils.rb +21 -0
- data/lib/snippet_cli/match_file_writer.rb +26 -0
- data/lib/snippet_cli/match_validator.rb +33 -0
- data/lib/snippet_cli/new_workflow.rb +98 -0
- data/lib/snippet_cli/replacement_text_collector.rb +55 -0
- data/lib/snippet_cli/replacement_validator.rb +35 -0
- data/lib/snippet_cli/replacement_wizard.rb +100 -0
- data/lib/snippet_cli/schema_validator.rb +27 -0
- data/lib/snippet_cli/snippet_builder.rb +133 -0
- data/lib/snippet_cli/string_helper.rb +11 -0
- data/lib/snippet_cli/table_formatter.rb +37 -0
- data/lib/snippet_cli/trigger_resolver.rb +67 -0
- data/lib/snippet_cli/ui.rb +90 -0
- data/lib/snippet_cli/var_builder/form_fields.rb +54 -0
- data/lib/snippet_cli/var_builder/name_collector.rb +69 -0
- data/lib/snippet_cli/var_builder/param_schema.rb +71 -0
- data/lib/snippet_cli/var_builder/params.rb +127 -0
- data/lib/snippet_cli/var_builder.rb +125 -0
- data/lib/snippet_cli/var_summary_renderer.rb +48 -0
- data/lib/snippet_cli/var_usage_checker.rb +48 -0
- data/lib/snippet_cli/var_yaml_renderer.rb +20 -0
- data/lib/snippet_cli/vars_block_renderer.rb +15 -0
- data/lib/snippet_cli/version.rb +3 -1
- data/lib/snippet_cli/wizard_context.rb +13 -0
- data/lib/snippet_cli/wizard_helpers/error_handler.rb +20 -0
- data/lib/snippet_cli/wizard_helpers/match_file_selector.rb +32 -0
- data/lib/snippet_cli/wizard_helpers/prompt_helpers.rb +57 -0
- data/lib/snippet_cli/wizard_helpers/validation_loop.rb +33 -0
- data/lib/snippet_cli/wizard_helpers.rb +8 -0
- data/lib/snippet_cli/yaml_line_resolver.rb +46 -0
- data/lib/snippet_cli/yaml_loader.rb +19 -0
- data/lib/snippet_cli/yaml_param_renderer.rb +33 -0
- data/lib/snippet_cli/yaml_scalar.rb +48 -0
- data/lib/snippet_cli.rb +44 -1
- metadata +134 -101
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.travis.yml +0 -9
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -17
- data/Rakefile +0 -10
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/lib/Setup.rb +0 -76
- data/lib/banner.rb +0 -16
- data/lib/snippet_cli/cli.rb +0 -62
- data/lib/snippet_cli/command.rb +0 -121
- data/lib/snippet_cli/commands/info.md +0 -20
- data/lib/snippet_cli/commands/info.rb +0 -36
- data/lib/snippet_cli/commands/setup.rb +0 -108
- data/lib/snippet_cli/templates/.gitkeep +0 -1
- data/lib/snippet_cli/templates/config/.gitkeep +0 -1
- data/lib/snippet_cli/templates/create/.gitkeep +0 -1
- data/lib/snippet_cli/templates/info/.gitkeep +0 -1
- data/lib/snippet_cli/templates/setup/.gitkeep +0 -1
- data/lib/snippet_generator.rb +0 -85
- data/snippet_cli-0.1.0.gem +0 -0
- data/snippet_cli-0.1.1.gem +0 -0
- data/snippet_cli-0.1.2.gem +0 -0
- data/snippet_cli-0.1.3.gem +0 -0
- data/snippet_cli-0.1.4.gem +0 -0
- data/snippet_cli-0.1.5.gem +0 -0
- data/snippet_cli-0.1.6.gem +0 -0
- data/snippet_cli-0.1.7.gem +0 -0
- data/snippet_cli-0.1.8.gem +0 -0
- data/snippet_cli-0.1.9.gem +0 -0
- data/snippet_cli-0.2.0.gem +0 -0
- data/snippet_cli-0.2.1.gem +0 -0
- data/snippet_cli-0.2.2.gem +0 -0
- data/snippet_cli-0.2.3.gem +0 -0
- data/snippet_cli-0.2.4.gem +0 -0
- data/snippet_cli-0.2.6.gem +0 -0
- data/snippet_cli-0.2.7.gem +0 -0
- data/snippet_cli-0.2.8.gem +0 -0
- data/snippet_cli.gemspec +0 -37
|
@@ -0,0 +1,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 <
|
|
16
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|