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,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
|