sod 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
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