sod 0.0.0

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.
data/lib/sod/action.rb ADDED
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Sod
6
+ # A generic action (and DSL) from which to inherit and build custom actions from.
7
+ # :reek:TooManyInstanceVariables
8
+ class Action
9
+ extend Forwardable
10
+
11
+ def self.inherited base
12
+ super
13
+ base.class_eval { @attributes = {} }
14
+ end
15
+
16
+ def self.description text
17
+ @description ? fail(Error, "Description can only be defined once.") : @description = text
18
+ end
19
+
20
+ def self.ancillary(*lines)
21
+ @ancillary ? fail(Error, "Ancillary can only be defined once.") : @ancillary = lines
22
+ end
23
+
24
+ def self.on(aliases, **keywords)
25
+ fail Error, "On can only be defined once." if @attributes.any?
26
+
27
+ @attributes.merge! keywords, aliases: Array(aliases)
28
+ end
29
+
30
+ def self.default &block
31
+ @default ? fail(Error, "Default can only be defined once.") : @default = block
32
+ end
33
+
34
+ delegate [*Models::Action.members, :handle, :to_a, :to_h] => :record
35
+
36
+ attr_reader :record
37
+
38
+ def initialize context: Context::EMPTY, model: Models::Action
39
+ klass = self.class
40
+
41
+ @context = context
42
+
43
+ @record = model[
44
+ **klass.instance_variable_get(:@attributes),
45
+ description: klass.instance_variable_get(:@description),
46
+ ancillary: Array(klass.instance_variable_get(:@ancillary)).compact,
47
+ default: load_default
48
+ ]
49
+
50
+ verify_argument
51
+ end
52
+
53
+ def call(*)
54
+ fail NotImplementedError,
55
+ "`#{self.class}##{__method__} #{method(__method__).parameters}` must be implemented."
56
+ end
57
+
58
+ def inspect
59
+ attributes = record.to_h.map { |key, value| "#{key}=#{value.inspect}" }
60
+ %(#<#{self.class} @context=#{context.inspect} #{attributes.join ", "}>)
61
+ end
62
+
63
+ def to_proc = method(:call).to_proc
64
+
65
+ protected
66
+
67
+ attr_reader :context
68
+
69
+ private
70
+
71
+ def verify_argument
72
+ return unless argument && !argument.start_with?("[") && default
73
+
74
+ fail Error, "Required argument can't be used with default."
75
+ end
76
+
77
+ def load_default
78
+ klass = self.class
79
+ fallback = klass.instance_variable_get(:@attributes)[:default].method :itself
80
+
81
+ (klass.instance_variable_get(:@default) || fallback).call
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Sod
6
+ # A generic command (and DSL) from which to inherit and build custom commands from.
7
+ # :reek:TooManyInstanceVariables
8
+ class Command
9
+ extend Forwardable
10
+
11
+ include Import[:logger]
12
+
13
+ def self.inherited base
14
+ super
15
+ base.class_eval { @actions = Set.new }
16
+ end
17
+
18
+ def self.handle text
19
+ @handle ? fail(Error, "Handle can only be defined once.") : @handle = text
20
+ end
21
+
22
+ def self.description text
23
+ @description ? fail(Error, "Description can only be defined once.") : @description = text
24
+ end
25
+
26
+ def self.ancillary(*lines)
27
+ @ancillary ? fail(Error, "Ancillary can only be defined once.") : @ancillary = lines
28
+ end
29
+
30
+ def self.on(action, *positionals, **keywords) = @actions.add [action, positionals, keywords]
31
+
32
+ delegate Models::Command.members => :record
33
+
34
+ attr_reader :record
35
+
36
+ def initialize(context: Context::EMPTY, model: Models::Command, **)
37
+ super(**)
38
+ klass = self.class
39
+ @context = context
40
+
41
+ @record = model[
42
+ handle: klass.instance_variable_get(:@handle),
43
+ description: klass.instance_variable_get(:@description),
44
+ ancillary: Array(klass.instance_variable_get(:@ancillary)).compact,
45
+ actions: Set[*build_actions],
46
+ operation: method(:call)
47
+ ]
48
+ end
49
+
50
+ def call
51
+ logger.debug { "`#{self.class}##{__method__}}` called without implementation. Skipped." }
52
+ end
53
+
54
+ def inspect
55
+ attributes = record.to_h
56
+ .map { |key, value| "#{key}=#{value.inspect}" }
57
+ .join ", "
58
+
59
+ "#<#{self.class} @logger=#{logger.inspect} @context=#{context.inspect} #{attributes}>"
60
+ end
61
+
62
+ protected
63
+
64
+ attr_reader :context
65
+
66
+ private
67
+
68
+ def build_actions
69
+ self.class.instance_variable_get(:@actions).map do |action, positionals, keywords|
70
+ action.new(*positionals, **keywords.merge!(context:))
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cogger"
4
+ require "dry/container"
5
+ require "optparse"
6
+ require "tone"
7
+
8
+ module Sod
9
+ # The primary container.
10
+ module Container
11
+ extend Dry::Container::Mixin
12
+
13
+ register(:client) { OptionParser.new nil, 40, " " }
14
+ register(:color) { Tone.new }
15
+ register(:kernel) { Kernel }
16
+ register(:logger) { Cogger.new formatter: :emoji }
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sod
4
+ # Provides a sharable, read-only, context for commands and actions.
5
+ class Context
6
+ EMPTY = new.freeze
7
+
8
+ def self.[](...) = new(...)
9
+
10
+ def initialize **attributes
11
+ @attributes = attributes
12
+ end
13
+
14
+ # :reek:ControlParameter
15
+ def [] default, fallback
16
+ default || public_send(fallback)
17
+ rescue NoMethodError
18
+ raise Error, "Invalid context. Default or fallback (#{fallback.inspect}) values are missing."
19
+ end
20
+
21
+ def to_h = attributes.dup
22
+
23
+ def method_missing(name, *) = respond_to_missing?(name) ? attributes[name] : super(name, *)
24
+
25
+ private
26
+
27
+ attr_reader :attributes
28
+
29
+ def respond_to_missing? name, include_private = false
30
+ (attributes && attributes.key?(name)) || super
31
+ end
32
+ end
33
+ end
data/lib/sod/error.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sod
4
+ # The namespaced root of all errors for this gem.
5
+ class Error < StandardError
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sod
4
+ module Graph
5
+ # Loads and decorates option parsers within graph.
6
+ class Loader
7
+ include Import[:client]
8
+
9
+ using Refines::OptionParsers
10
+
11
+ def initialize(graph, **)
12
+ super(**)
13
+ @graph = graph
14
+ @registry = {}
15
+ end
16
+
17
+ def call
18
+ registry.clear
19
+ load graph
20
+ graph.children.each { |child| visit child, child.handle }
21
+ registry
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :graph, :registry
27
+
28
+ def visit command, key = ""
29
+ load command, key
30
+ command.children.each { |child| visit child, "#{key} #{child.handle}".strip }
31
+ end
32
+
33
+ def load node, key = ""
34
+ parser = client.replicate
35
+ node.actions.each { |action| parser.on(*action.to_a, action.to_proc) }
36
+ registry[key] = [parser, node]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/arrays"
4
+
5
+ module Sod
6
+ module Graph
7
+ # A generic graph node (and DSL) from which to build multiple lineages with.
8
+ Node = Struct.new :handle, :description, :ancillary, :actions, :operation, :children do
9
+ using Refinements::Arrays
10
+
11
+ def initialize(**)
12
+ super
13
+ self[:actions] = Set.new actions
14
+ self[:children] = Set.new children
15
+ self[:ancillary] = Array ancillary
16
+ @depth = 0
17
+ @lineage = []
18
+ end
19
+
20
+ def get_action *lineage
21
+ handle = lineage.pop
22
+ get_actions(*lineage).find { |action| action.handle.include? handle }
23
+ end
24
+
25
+ def get_actions *lineage, node: self
26
+ lineage.empty? ? node.actions : get(lineage, node, __method__)
27
+ end
28
+
29
+ def get_child(*lineage, node: self) = lineage.empty? ? node : get(lineage, node, __method__)
30
+
31
+ def on(object, *, **, &block)
32
+ lineage.clear if depth.zero?
33
+
34
+ process(object, *, **)
35
+
36
+ increment
37
+ instance_eval(&block) if block
38
+ decrement
39
+ end
40
+
41
+ def call = (operation.call if operation)
42
+
43
+ private
44
+
45
+ attr_reader :lineage
46
+
47
+ attr_accessor :depth
48
+
49
+ # :reek:TooManyStatements
50
+ # rubocop:todo Metrics/AbcSize
51
+ def process(object, *, **)
52
+ ancestry = object.is_a?(Class) ? object.ancestors : []
53
+
54
+ if ancestry.include? Command
55
+ add_child(*lineage, self.class[**object.new(*, **).record.to_h])
56
+ elsif object.is_a? String
57
+ add_inline_command(object, *, **)
58
+ elsif ancestry.include? Action
59
+ add_action(*lineage, object.new(*, **))
60
+ else
61
+ fail Error, "Invalid command or action. Unable to add: #{object.inspect}."
62
+ end
63
+ end
64
+ # rubocop:enable Metrics/AbcSize
65
+
66
+ def add_inline_command handle, *positionals
67
+ description, *ancillary = positionals
68
+
69
+ fail Error, <<~CONTENT unless handle && description
70
+ Unable to add command. Invalid handle or description (both are required):
71
+ - Handle: #{handle.inspect}
72
+ - Description: #{description.inspect}
73
+ CONTENT
74
+
75
+ add_child(*lineage, self.class[handle:, description:, ancillary: ancillary.compact])
76
+ end
77
+
78
+ def add_child *lineage
79
+ node = lineage.pop
80
+ handle = node.handle
81
+ tracked_lineage = self.lineage
82
+
83
+ add lineage[...depth], node, :children
84
+ tracked_lineage.replace_at depth, handle
85
+ end
86
+
87
+ def add_action(*lineage) = add lineage, lineage.pop, :actions
88
+
89
+ def add lineage, node, message
90
+ get_child(*lineage).then { |child| child.public_send(message).add node }
91
+ end
92
+
93
+ def get lineage, node, message
94
+ handle = lineage.shift
95
+ node = node.children.find { |child| child.handle == handle }
96
+
97
+ fail Error, "Unable to find command or action: #{handle.inspect}." unless node
98
+
99
+ public_send(message, *lineage, node:)
100
+ end
101
+
102
+ def increment = self.depth += 1
103
+
104
+ def decrement = self.depth -= 1
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sod
4
+ module Graph
5
+ # Runs the appropriate parser for given command line arguments.
6
+ class Runner
7
+ include Import[:client, :logger]
8
+
9
+ using Refines::OptionParsers
10
+
11
+ HELP_PATTERN = /
12
+ \A # Start of string.
13
+ -h # Short alias.
14
+ | # Or.
15
+ --help # Long alias.
16
+ \Z # End of string.
17
+ /x
18
+
19
+ # rubocop:todo Metrics/ParameterLists
20
+ def initialize(graph, help_pattern: HELP_PATTERN, loader: Loader, **)
21
+ super(**)
22
+ @graph = graph
23
+ @registry = loader.new(graph).call
24
+ @help_pattern = help_pattern
25
+ @lineage = +""
26
+ end
27
+ # rubocop:enable Metrics/ParameterLists
28
+
29
+ # :reek:DuplicateMethodCall
30
+ # :reek:TooManyStatements
31
+ def call arguments = ARGV
32
+ lineage.clear
33
+ visit arguments.dup
34
+ rescue OptionParser::ParseError => error
35
+ log_error error.message
36
+ rescue Sod::Error => error
37
+ log_error error.message
38
+ help
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :graph, :registry, :help_pattern, :lineage
44
+
45
+ # :reek:TooManyStatements
46
+ def visit arguments
47
+ if arguments.empty? || arguments.any? { |argument| argument.match? help_pattern }
48
+ usage(*arguments)
49
+ else
50
+ parser, node = registry.fetch lineage, client
51
+ parser.order! arguments, command: node do |command|
52
+ lineage.concat(" ", command).tap(&:strip!)
53
+ visit arguments
54
+ end
55
+ end
56
+ end
57
+
58
+ def usage(*arguments)
59
+ commands = arguments.grep_v help_pattern
60
+ commands = lineage.split if commands.empty?
61
+ help(*commands)
62
+ end
63
+
64
+ def help(*commands)
65
+ graph.get_action("help").then { |action| action.call(*commands) if action }
66
+ end
67
+
68
+ def log_error(message) = logger.error { message.capitalize }
69
+ end
70
+ end
71
+ end
data/lib/sod/import.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "infusible"
4
+
5
+ module Sod
6
+ Import = Infusible.with Container
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/arrays"
4
+
5
+ module Sod
6
+ module Models
7
+ # Defines all attributes of an action.
8
+ Action = Data.define(
9
+ :aliases,
10
+ :argument,
11
+ :type,
12
+ :allow,
13
+ :default,
14
+ :description,
15
+ :ancillary
16
+ ) do
17
+ using Refinements::Arrays
18
+
19
+ def initialize aliases: nil,
20
+ argument: nil,
21
+ type: nil,
22
+ allow: nil,
23
+ default: nil,
24
+ description: nil,
25
+ ancillary: nil
26
+ super
27
+ end
28
+
29
+ def handle = [Array(aliases).join(", "), argument].tap(&:compact!).join " "
30
+
31
+ def to_a = [*handles, type, allow, description, *ancillary].tap(&:compress!)
32
+
33
+ private
34
+
35
+ def handles = Array(aliases).map { |item| [item, argument].tap(&:compact!).join " " }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sod
4
+ module Models
5
+ Command = Data.define :handle, :description, :ancillary, :actions, :operation do
6
+ def initialize handle:, description:, actions:, operation:, ancillary: []
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/pathnames"
4
+
5
+ module Sod
6
+ module Prefabs
7
+ module Actions
8
+ module Config
9
+ # Creates project configuration.
10
+ class Create < Action
11
+ include Import[:kernel, :logger]
12
+
13
+ using Refinements::Pathnames
14
+
15
+ description "Create default configuration."
16
+
17
+ ancillary "Prompts for local or global path."
18
+
19
+ on %w[-c --create]
20
+
21
+ def initialize(xdg_config = nil, defaults_path: nil, **)
22
+ super(**)
23
+ @xdg_config = context[xdg_config, :xdg_config]
24
+ @defaults_path = Pathname context[defaults_path, :defaults_path]
25
+ end
26
+
27
+ def call(*)
28
+ ARGV.clear
29
+ check_defaults && choose
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :xdg_config, :defaults_path
35
+
36
+ def check_defaults
37
+ return true if defaults_path.exist?
38
+
39
+ logger.fatal { "Default configuration doesn't exist: #{defaults_path.to_s.inspect}." }
40
+ kernel.abort
41
+ false
42
+ end
43
+
44
+ def choose
45
+ kernel.print "Would you like to create (g)lobal, (l)ocal, or (n)o configuration? " \
46
+ "(g/l/n)? "
47
+ response = kernel.gets.chomp
48
+
49
+ case response
50
+ when "g" then create xdg_config.global
51
+ when "l" then create xdg_config.local
52
+ else quit
53
+ end
54
+ end
55
+
56
+ # :reek:TooManyStatements
57
+ def create path
58
+ path_info = path.to_s.inspect
59
+
60
+ if path.exist?
61
+ logger.warn { "Skipped. Configuration exists: #{path_info}." }
62
+ else
63
+ defaults_path.copy path.make_ancestors
64
+ logger.info { "Created: #{path_info}." }
65
+ end
66
+ end
67
+
68
+ def quit
69
+ logger.info { "Creation canceled." }
70
+ kernel.exit
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/pathnames"
4
+ require "refinements/strings"
5
+
6
+ module Sod
7
+ module Prefabs
8
+ module Actions
9
+ module Config
10
+ # Deletes project configuration.
11
+ class Delete < Action
12
+ include Import[:kernel, :logger]
13
+
14
+ using Refinements::Pathnames
15
+ using Refinements::Strings
16
+
17
+ description "Delete project configuration."
18
+
19
+ ancillary "Prompts for confirmation."
20
+
21
+ on %w[-d --delete]
22
+
23
+ # :reek:ControlParameter
24
+ def initialize(path = nil, **)
25
+ super(**)
26
+ @path = Pathname(path || context.xdg_config.active)
27
+ end
28
+
29
+ def call(*)
30
+ ARGV.clear
31
+
32
+ return confirm if path.exist?
33
+
34
+ logger.warn { "Skipped. Configuration doesn't exist: #{path_info}." }
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :path
40
+
41
+ def confirm
42
+ kernel.print "Are you sure you want to delete #{path_info} (y/n)? "
43
+ response = kernel.gets.chomp.to_bool
44
+
45
+ if response
46
+ path.delete
47
+ info "Deleted: #{path_info}."
48
+ else
49
+ info "Skipped: #{path_info}."
50
+ end
51
+ end
52
+
53
+ def path_info = path.to_s.inspect
54
+
55
+ def info(message) = logger.info { message }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/pathnames"
4
+
5
+ module Sod
6
+ module Prefabs
7
+ module Actions
8
+ module Config
9
+ # Edits project configuration.
10
+ class Edit < Action
11
+ include Import[:kernel, :logger]
12
+
13
+ using Refinements::Pathnames
14
+
15
+ description "Edit project configuration."
16
+
17
+ on %w[-e --edit]
18
+
19
+ # :reek:ControlParameter
20
+ def initialize(path = nil, **)
21
+ super(**)
22
+ @path = Pathname(path || context.xdg_config.active)
23
+ end
24
+
25
+ def call(*)
26
+ return unless check
27
+
28
+ logger.info { "Editing: #{path.to_s.inspect}." }
29
+ kernel.system "$EDITOR #{path}"
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :path
35
+
36
+ def check
37
+ return true if path.exist?
38
+
39
+ logger.error { "Configuration doesn't exist: #{path.to_s.inspect}." }
40
+ kernel.abort
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end