distorted 0.5.4 → 0.7.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +661 -0
  3. data/README.md +5 -140
  4. data/bin/console +14 -0
  5. data/bin/distorted +6 -0
  6. data/bin/setup +8 -0
  7. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Less_Perfect_DOS_VGA.png +0 -0
  8. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/More_Perfect_DOS_VGA.png +0 -0
  9. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Perfect_DOS_VGA.png +0 -0
  10. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/less_more_perfect_dos_vga_437.html +52 -0
  11. data/font/1252/LICENSE/PerfectDOSVGA437/font-comment.php@file=perfect_dos_vga_437.html +5 -0
  12. data/font/1252/LessPerfectDOSVGA.ttf +0 -0
  13. data/font/1252/MorePerfectDOSVGA.ttf +0 -0
  14. data/font/1252/Perfect DOS VGA 437 Win.ttf +0 -0
  15. data/font/437/Perfect DOS VGA 437.ttf +0 -0
  16. data/font/437/dos437.txt +72 -0
  17. data/font/65001/Anonymous Pro B.ttf +0 -0
  18. data/font/65001/Anonymous Pro BI.ttf +0 -0
  19. data/font/65001/Anonymous Pro I.ttf +0 -0
  20. data/font/65001/Anonymous Pro.ttf +0 -0
  21. data/font/65001/LICENSE/AnonymousPro/FONTLOG.txt +45 -0
  22. data/font/65001/LICENSE/AnonymousPro/OFL-FAQ.txt +235 -0
  23. data/font/65001/LICENSE/AnonymousPro/OFL.txt +94 -0
  24. data/font/65001/LICENSE/AnonymousPro/README.txt +55 -0
  25. data/font/850/ProFont-Bold-01/LICENSE +22 -0
  26. data/font/850/ProFont-Bold-01/readme.txt +28 -0
  27. data/font/850/ProFontWindows-Bold.ttf +0 -0
  28. data/font/850/ProFontWindows.ttf +0 -0
  29. data/font/850/Profont/LICENSE +22 -0
  30. data/font/850/Profont/readme.txt +31 -0
  31. data/font/932/LICENSE/README-ttf.txt +213 -0
  32. data/font/932/mona.ttf +0 -0
  33. data/lib/distorted.rb +2 -0
  34. data/lib/distorted/checking_you_out.rb +219 -0
  35. data/lib/distorted/checking_you_out/README +4 -0
  36. data/lib/distorted/checking_you_out/application.yaml +33 -0
  37. data/lib/distorted/checking_you_out/font.yaml +29 -0
  38. data/lib/distorted/checking_you_out/image.yaml +108 -0
  39. data/lib/distorted/click_again.rb +333 -0
  40. data/lib/distorted/element_of_media.rb +2 -0
  41. data/lib/distorted/element_of_media/change.rb +119 -0
  42. data/lib/distorted/element_of_media/compound.rb +120 -0
  43. data/lib/distorted/error_code.rb +51 -0
  44. data/lib/distorted/floor.rb +17 -0
  45. data/lib/distorted/invoker.rb +97 -0
  46. data/lib/distorted/media_molecule.rb +58 -0
  47. data/lib/distorted/media_molecule/font.rb +195 -0
  48. data/lib/distorted/media_molecule/image.rb +33 -0
  49. data/lib/distorted/media_molecule/pdf.rb +44 -0
  50. data/lib/distorted/media_molecule/svg.rb +45 -0
  51. data/lib/distorted/media_molecule/text.rb +203 -0
  52. data/lib/distorted/media_molecule/video.rb +18 -0
  53. data/lib/distorted/modular_technology/gstreamer.rb +174 -0
  54. data/lib/distorted/modular_technology/pango.rb +90 -0
  55. data/lib/distorted/modular_technology/ttfunk.rb +48 -0
  56. data/lib/distorted/modular_technology/vips.rb +17 -0
  57. data/lib/distorted/modular_technology/vips/foreign.rb +489 -0
  58. data/lib/distorted/modular_technology/vips/load.rb +133 -0
  59. data/lib/distorted/modular_technology/vips/save.rb +161 -0
  60. data/lib/distorted/monkey_business/encoding.rb +317 -0
  61. data/lib/distorted/monkey_business/hash.rb +18 -0
  62. data/lib/distorted/monkey_business/set.rb +15 -0
  63. data/lib/distorted/monkey_business/string.rb +6 -0
  64. data/lib/distorted/triple_counter.rb +52 -0
  65. data/lib/distorted/version.rb +22 -0
  66. data/test/distorted_test.rb +11 -0
  67. data/test/test_helper.rb +4 -0
  68. metadata +130 -20
@@ -0,0 +1,2 @@
1
+ require 'distorted/element_of_media/change'
2
+ require 'distorted/element_of_media/compound'
@@ -0,0 +1,119 @@
1
+ module Cooltrainer
2
+
3
+ # Fun Ruby Fact™: `false` is always object_id 0
4
+ # https://skorks.com/2009/09/true-false-and-nil-objects-in-ruby/
5
+ # irb(main):650:0> true.object_id
6
+ # => 20
7
+ # irb(main):651:0> false.object_id
8
+ # => 0
9
+ BOOLEAN_VALUES = Set[false, true]
10
+
11
+
12
+ # Struct to encapsulate all the data needed to perform one (1) MIME::Type transformation
13
+ # of a source media file into any supported MIME::Type, possibly even the same type as input.
14
+ Change = Struct.new(:type, :src, :basename, :molecule, :tag, :breaks, :atoms, keyword_init: true) do
15
+
16
+ # Customize the destination filename and other values before doing the normal Struct setup.
17
+ def initialize(type, src: nil, molecule: nil, tag: nil, breaks: Array.new, **atoms)
18
+ # `name` might have a leading slash if referenced as an absolute path as the Tag.
19
+ basename = File.basename(src, '.*'.freeze).reverse.chomp('/'.freeze).reverse
20
+
21
+ # Set the &default_proc on the kwarg-glob Hash instead of making a new Hash,
22
+ atoms.default_proc = lambda { |h,k| h[k] = Cooltrainer::Atom.new }
23
+ atoms.transform_values {
24
+ # We might get Atoms already instantiated, but do it for any that aren't.
25
+ # We won't have a default value for them in that case.
26
+ |v| v.is_a?(Cooltrainer::Atom) ? atom : Cooltrainer::Atom.new(v, nil)
27
+ }.each_key { |k|
28
+ # Define accessors for context-specific :atoms keys/values that aren't normal Struct members.
29
+ self.singleton_class.define_method(k) { self[:atoms]&.fetch(k, nil)&.get }
30
+ self.singleton_class.define_method("#{k}=".to_sym) { |v| self[:atoms][k] = v }
31
+ }
32
+
33
+ # And now back to your regularly-scheduled Struct
34
+ super(type: type, src: src, basename: basename, molecule: molecule, tag: tag, breaks: breaks, atoms: atoms)
35
+ end
36
+
37
+ # Returns the Change Type's :preferred_extension as a String with leading dot (.)
38
+ def extname
39
+ @extname ||= begin
40
+ dot = '.'.freeze unless type.preferred_extension.nil? || type.preferred_extension&.empty?
41
+ "#{dot}#{type.preferred_extension}"
42
+ end
43
+ end
44
+
45
+ # Returns an Array[String] of filenames this Change should generate,
46
+ # one 'full'/'original' plus any limit-breaks,
47
+ # e.g. ["DistorteD.png", "DistorteD-333.png", "DistorteD-555.png", "DistorteD-888.png", "DistorteD-1111.png"]
48
+ def names
49
+ Array[''.freeze].concat(self[:breaks]).map { |b|
50
+ filetag = (b.nil? || b&.to_s.empty?) ? ''.freeze : '-'.concat(b.to_s)
51
+ "#{self[:basename]}#{"-#{self.tag}" unless self.tag.nil?}#{filetag}#{extname}"
52
+ }
53
+ end
54
+
55
+ # Returns a String describing the :names but rolled into one,
56
+ # e.g. "IIDX-turntable-(400|800|1500).png"
57
+ def name
58
+ tags = self[:breaks].length > 1 ? "-(#{self[:breaks].join('|'.freeze)})" : ''.freeze
59
+ "#{self.basename}#{tags}#{self.extname}"
60
+ end
61
+
62
+ # Returns an Array[String] of all absolute destination paths this Change should generate,
63
+ # given a root destination directory.
64
+ def paths(dest_root = ''.freeze) # Empty String will expand to current working directory
65
+ output_dir = self[:atoms]&.fetch(:dir, ''.freeze)
66
+ return self.names.map { |n| File.join(File.expand_path(dest_root), output_dir, n) }
67
+ end
68
+
69
+ # Returns a String absolute destination path for only one limit-break.
70
+ def path(dest_root, break_value)
71
+ output_dir = self[:atoms]&.fetch(:dir, ''.freeze)
72
+ return File.join(File.expand_path(dest_root), output_dir, "#{self.basename}#{"-#{self.tag}" unless self.tag.nil?}-#{break_value}#{self.extname}")
73
+ end
74
+
75
+ # A generic version of Struct#to_hash was rejected with good reason,
76
+ # but I'm going to use it here because I want the implicit Struct-to-Hash
77
+ # conversion to let me destructure these Structs with a double-splat:
78
+ # https://bugs.ruby-lang.org/issues/4862
79
+ #
80
+ # Defining this method causes Ruby 2.7 to emit a "Using the last argument as keyword parameters is deprecated" warning
81
+ # if this Struct is passed to a method as the final positional argument! Ruby 2.7 will actually do the
82
+ # conversion when calling the method in that scenario, causing incorrect behavior to methods expecting Struct.
83
+ # This is why DD-Floor's `:write` and DD-Jekyll's `:render_to_output_buffer` pass an empty kwargs Hash.
84
+ # https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
85
+ def to_hash # Implicit
86
+ Hash[self.members.reject{|m| m == :atoms}.zip(self.values.reject{|v| v.is_a?(Hash)})].merge(self[:atoms].transform_values(&:get))
87
+ end
88
+ # Struct#to_h does exist in stdlib, but redefine its behavior to match our `:to_hash`.
89
+ def to_h # Explicit
90
+ Hash[self.members.reject{|m| m == :atoms}.zip(self.values.reject{|v| v.is_a?(Hash)})].merge(self[:atoms].transform_values(&:get))
91
+ end
92
+ def dig(*keys); self.to_hash.dig(*keys); end
93
+
94
+ # Support setting Atoms that were not defined at instantiation.
95
+ def method_missing(meth, *a, **k, &b)
96
+ # Are we a setter?
97
+ if meth.to_s[-1] == '='.freeze
98
+ # Set the :value of an existing Atom Struct
99
+ self[:atoms][meth.to_s.chomp('='.freeze).to_sym].value = a.first
100
+ else
101
+ self[:atoms]&.fetch(meth, nil)
102
+ end
103
+ end
104
+
105
+ end # Struct Change
106
+
107
+
108
+ # Struct to wrap just the user and default values for a Compound or just for freeform usage.
109
+ Atom = Struct.new(:value, :default) do
110
+ # Return a value if set, otherwise a default. Both can be `nil`.
111
+ def get; self.value || self.default; end
112
+ # Override these default Struct methods with ones that reference our :get
113
+ def to_s; self.get.to_s; end # Explicit
114
+ def to_str; self.get.to_s; end # Implicit
115
+ # Send any unknown message through to a value/default.
116
+ def method_missing(meth, *a, **k, &b); self.get.send(meth, *a, **k, &b); end
117
+ end
118
+
119
+ end
@@ -0,0 +1,120 @@
1
+ require 'optparse'
2
+ require 'date'
3
+
4
+ module Cooltrainer
5
+
6
+ # This is defined in writing in a comment in optparse.rb's RDoc,
7
+ # but I can't seem to find anywhere it's available directly in code,
8
+ # so I am going to build my own.
9
+ # Maybe I am missing something obvious, in which case I should use that
10
+ # and get rid of this :)
11
+ # Based on https://ruby-doc.org/stdlib/libdoc/optparse/rdoc/OptionParser.html#class-OptionParser-label-Type+Coercion
12
+ OPTIONPARSER_COERSIONS = [
13
+ Date,
14
+ DateTime,
15
+ Time,
16
+ URI,
17
+ #Shellwords, # Stock in optparse, but under autoload. I don't want it.
18
+ String,
19
+ Integer,
20
+ Float,
21
+ Numeric,
22
+ TrueClass,
23
+ FalseClass,
24
+ Array,
25
+ Regexp,
26
+ ].concat(OptionParser::Acceptables::constants)
27
+
28
+
29
+ # Struct to wrap a MediaMolecule option/attribute datum.
30
+ Compound = Struct.new(:isotopes, :molecule, :valid, :default, :blurb, keyword_init: true) do
31
+
32
+ # Massage the data then call `super` to set the members/values and create the accessors.
33
+ def initialize(isotopes, molecule: nil, valid: nil, default: nil, blurb: nil)
34
+ super(
35
+ # The first argument defines the aliases for a single option and may be just a Symbol
36
+ # or may be an Enumerable[Symbol] in which case all items after the first are aliases for the first.
37
+ isotopes: isotopes.is_a?(Enumerable) ? isotopes.to_a : Array[isotopes],
38
+ # Hint the MediaMolecule that should execute this Change.
39
+ molecule: molecule,
40
+ # Valid values for this option may be expressed as a Class a value must be an instance of,
41
+ # a Regexp a String value must match, an Enumerable that a valid value must be in,
42
+ # a Range a Float/Integer must be within, a special Boolean Set, etc etc.
43
+ valid: case valid
44
+ when Set then valid.to_a
45
+ else valid
46
+ end,
47
+ # Optional default value to use when unset.
48
+ default: default,
49
+ # String description of this option's effect.
50
+ blurb: blurb,
51
+ )
52
+ end
53
+
54
+ # The first isotope is the """real""" option name. Any others are aliases for it.
55
+ def element; self.isotopes&.first; end
56
+ def to_s; self.element.to_s; end
57
+
58
+ # Returns a longform String representation of one option.
59
+ def inspect
60
+ # Intentionally not including the blurb here since they are pretty long and messy.
61
+ "#{self.isotopes.length > 1 ? self.isotopes : self.element}: #{"#{self.valid} " if self.valid}#{"(#{self.default})" if self.default}"
62
+ end
63
+
64
+ # Returns an Array of properly-formatted OptionParser::Switch strings for this Compound.
65
+ def to_options
66
+ # @isotopes is a Hash[Symbol] => Compound, allowing for Compound aliasing
67
+ # to multiple Hash keys, e.g. libvips' `:Q` and `:quality` are two Hash keys
68
+ # referencing the same Compound object.
69
+ self.isotopes.each_with_object(Array[]) { |aka, commands|
70
+ # Every Switch has at least one leading dash, and longer ones have two,
71
+ # e.g. `-Q` vs `--quality`.
72
+ command = "-"
73
+ if aka.length > 1
74
+ command << '-'.freeze
75
+ end
76
+
77
+ # Compounds that take a boolean should format their Switch string
78
+ # as `--[no]-whatever` (including the brackets!) instead of taking
79
+ # any kind of boolean-ish argument like true/false/yes/no.
80
+ #
81
+ # TODO: There seems to be a bug with Ruby optparse and multiple of these
82
+ # "--[no]-whatever"-style Switches where only the final Switch will display,
83
+ # so disable this for now in favor of separate --whatever/--no-whatever.
84
+ # I have a very basic standalone repro case that fails, so it's not just DD.
85
+ #
86
+ #if @valid == BOOLEAN_VALUES or @valid == BOOLEAN_VALUES.to_a
87
+ # command << '[no]-'.freeze
88
+ #end
89
+
90
+ # Add the alias to form the command.
91
+ command << aka.to_s
92
+
93
+ # Format the valid values and/or default value and stick it on the end.
94
+ # https://ruby-doc.org/stdlib/libdoc/optparse/rdoc/OptionParser.html#class-OptionParser-label-Type+Coercion
95
+ if self.valid.is_a?(Range)
96
+ command << " [#{self.valid.to_s}]"
97
+ elsif self.valid == BOOLEAN_VALUES or self.valid == BOOLEAN_VALUES.to_a
98
+ # Intentional no-op
99
+ elsif self.valid.is_a?(Enumerable)
100
+ command << " [#{self.valid.join(', '.freeze)}]"
101
+ elsif not default.nil?
102
+ command << " [#{self.default}]"
103
+ else
104
+ command << " [#{self.element.upcase}]"
105
+ end
106
+
107
+ commands << command
108
+
109
+ # HACK around issue with multiple "--[no]-whatever"-style long arguments.
110
+ # See above note and the commented-out implementation I'd like to use
111
+ # instead of this. Remove this iff I can figure out what's wrong there.
112
+ if self.valid == BOOLEAN_VALUES or self.valid == BOOLEAN_VALUES.to_a
113
+ commands << "--no-#{aka}"
114
+ end
115
+ }
116
+ end # to_options
117
+
118
+ end # Compound
119
+
120
+ end
@@ -0,0 +1,51 @@
1
+ # https://ruby-doc.org/core/Exception.html sez:
2
+ # "It is recommended that a library should have one subclass of StandardError
3
+ # or RuntimeError and have specific exception types inherit from it.
4
+ # This allows the user to rescue a generic exception type to catch
5
+ # all exceptions the library may raise even if future versions of
6
+ # the library add new exception subclasses."
7
+ class StandardDistorteDError < StandardError
8
+ end
9
+
10
+ # The built-in NotImplementedError is for "when a feature is not implemented
11
+ # on the current platform", so make our own more appropriate ones.
12
+ class MediaTypeNotImplementedError < StandardDistorteDError
13
+ attr_reader :name
14
+ def initialize(name)
15
+ super
16
+ @name = name
17
+ end
18
+
19
+ def message
20
+ "No supported media type for #{name}"
21
+ end
22
+ end
23
+
24
+ class MediaTypeOutputNotImplementedError < MediaTypeNotImplementedError
25
+ attr_reader :type, :context
26
+ def initialize(name, type, context)
27
+ super(name)
28
+ @type = type
29
+ @context = context
30
+ end
31
+
32
+ def message
33
+ "Unable to save #{name} as #{type.to_s} from #{context}"
34
+ end
35
+ end
36
+
37
+ class MediaTypeNotFoundError < StandardDistorteDError
38
+ attr_reader :name
39
+ def initialize(name)
40
+ super
41
+ @name = name
42
+ end
43
+
44
+ def message
45
+ "Failed to detect media type for #{name}"
46
+ end
47
+ end
48
+
49
+
50
+ class OutOfDateLibraryError < LoadError
51
+ end
@@ -0,0 +1,17 @@
1
+
2
+ require 'set'
3
+ require 'distorted/monkey_business/set'
4
+
5
+ require 'distorted/invoker'
6
+ require 'distorted/click_again'
7
+ require 'distorted/checking_you_out'
8
+
9
+
10
+ module Cooltrainer; end
11
+ module Cooltrainer::DistorteD; end
12
+
13
+ class Cooltrainer::DistorteD::Floor
14
+ def initialize(src, dest, type: nil)
15
+ #TODO: Library-use entry-point
16
+ end
17
+ end
@@ -0,0 +1,97 @@
1
+
2
+ # Our custom Exceptions
3
+ require 'distorted/error_code'
4
+
5
+ # MIME::Typer
6
+ require 'distorted/checking_you_out'
7
+ require 'distorted/media_molecule'
8
+
9
+ # Set.to_hash
10
+ require 'distorted/monkey_business/set'
11
+ require 'set'
12
+
13
+
14
+ module Cooltrainer::DistorteD::Invoker
15
+ # Returns a Hash[MIME::Type] => Hash[MediaMolecule] => Hash[param_alias] => Compound
16
+ def lower_world
17
+ Cooltrainer::DistorteD::IMPLANTATION(:LOWER_WORLD).each_with_object(
18
+ Hash.new { |pile, type| pile[type] = Hash[] }
19
+ ) { |(key, types), pile|
20
+ types.each { |type, elements| pile.update(type => {key.molecule => elements}) { |k,o,n| o.merge(n) }}
21
+ }
22
+ end
23
+
24
+ # Returns a Hash[MediaMolecule] => Hash[MIME::Type] => Hash[param_alias] => Compound
25
+ def outer_limits(all: false)
26
+ Cooltrainer::DistorteD::IMPLANTATION(
27
+ :OUTER_LIMITS,
28
+ (all || type_mars.empty?) ? Cooltrainer::DistorteD::media_molecules : type_mars.each_with_object(Set[]) { |type, molecules|
29
+ molecules.merge(lower_world[type].keys)
30
+ },
31
+ ).each_with_object(Hash.new { |pile, type| pile[type] = Hash[] }) { |(key, types), pile|
32
+ types.each { |type, elements| pile.update(key.molecule => {type => elements}) { |k,o,n| o.merge(n) }}
33
+ }
34
+ end
35
+
36
+ # Filename without the dot-and-extension.
37
+ def basename
38
+ File.basename(@name, '.*')
39
+ end
40
+
41
+ # Returns a Set of MIME::Types common to the source file and our supported MediaMolecules.
42
+ # Each of these Molecules will be plugged to the current instance.
43
+ def type_mars
44
+ @type_mars ||= CHECKING::YOU::OUT(path, so_deep: true) & lower_world.keys.to_set
45
+ raise MediaTypeNotImplementedError.new(@name) if @type_mars.empty?
46
+ @type_mars
47
+ end
48
+
49
+ # MediaMolecule file-type plugger.
50
+ # Any call to a MIME::Type's distorted_method will end up here unless
51
+ # the Molecule that defines it has been `prepend`ed to our instance.
52
+ def method_missing(meth, *args, **kwargs, &block)
53
+ # Only consider method names with our prefixes.
54
+ if MIME::Type::DISTORTED_METHOD_PREFIXES.values.map(&:to_s).include?(meth.to_s.split(MIME::Type::SUB_TYPE_SEPARATORS)[0])
55
+ # TODO: Might need to handle cases here where the Set[Molecule]
56
+ # exists but none of them defined our method.
57
+ unless self.singleton_class.instance_variable_get(:@media_molecules)
58
+ unless outer_limits.empty?
59
+ self.singleton_class.instance_variable_set(
60
+ :@media_molecules,
61
+ outer_limits.keys.each_with_object(Set[]) { |molecule, molecules|
62
+ self.singleton_class.prepend(molecule)
63
+ molecules.add(molecule)
64
+ }
65
+ )
66
+ # `return` to ensure we don't fall through to #method_missing:super
67
+ # if we are going to do any work, otherwise a NoMethodError will
68
+ # still be raised despite the distorted_method :sends suceeding.
69
+ #
70
+ # Use :__send__ in case a Molecule defines a `:send` method.
71
+ # https://ruby-doc.org/core/Object.html#method-i-send
72
+ return self.send(meth, *args, **kwargs, &block)
73
+ end
74
+ end
75
+ end
76
+ # …and I still haven't found it! — What I'm looking for, that is.
77
+ # https://www.youtube.com/watch?v=xqse3vYcnaU
78
+ super
79
+ end
80
+
81
+ # Make sure :respond_to? works for yet-unplugged distorted_methods.
82
+ # http://blog.marc-andre.ca/2010/11/15/methodmissing-politely/
83
+ def respond_to_missing?(meth, *a)
84
+ # We can tell if a method looks like one of ours if it has at least 3 (maybe more!)
85
+ # underscore-separated components with a valid prefix as the first component
86
+ # and the media-type and sub-type as the rest, e.g.
87
+ #
88
+ # irb(main)> 'to_application_pdf'.split('_')
89
+ # => ["to", "application", "pdf"]
90
+ #
91
+ # irb(main)> CHECKING::YOU::OUT('.docx').first.distorted_file_method.to_s.split('_')
92
+ # => ["write", "application", "vnd", "openxmlformats", "officedocument", "wordprocessingml", "document"]
93
+ parts = meth.to_s.split(MIME::Type::SUB_TYPE_SEPARATORS)
94
+ MIME::Type::DISTORTED_METHOD_PREFIXES.values.map(&:to_s).include?(parts[0]) && parts.length > 2 || super(meth, *a)
95
+ end
96
+
97
+ end
@@ -0,0 +1,58 @@
1
+ require 'set'
2
+
3
+ module Cooltrainer; end
4
+ module Cooltrainer::DistorteD
5
+
6
+ # Discover DistorteD MediaMolecules bundled with this Gem
7
+ # TODO: and any installed as separate Gems.
8
+ @@loaded_molecules rescue begin
9
+ Dir[File.join(__dir__, 'media_molecule', '*.rb')].each { |molecule| require molecule }
10
+ @@loaded_molecules = true
11
+ end
12
+
13
+ # Returns a Set[Module] of our discovered MediaMolecules.
14
+ def self.media_molecules
15
+ Cooltrainer::DistorteD::Molecule.constants.map { |molecule|
16
+ Cooltrainer::DistorteD::Molecule::const_get(molecule)
17
+ }.to_set
18
+ end
19
+
20
+ # Reusable IMPLANTATION Hash key, since instances of the same Struct subclass are equal:
21
+ # irb> Pair = Struct.new(:uno, :dos)
22
+ # irb> lol = Pair.new(:a, 1)
23
+ # irb> rofl = Pair.new(:a, 1)
24
+ # irb> lol === rofl
25
+ # => true
26
+ KEY = Struct.new(:molecule, :constant, :inherit) do
27
+ # Descend into ancestor Modules by default.
28
+ def initialize(molecule, constant, inherit = true); super(molecule, constant, inherit); end
29
+ def inspect; "#{molecule}#{'∫'.freeze if inherit}::#{constant}"; end
30
+ end
31
+
32
+ # Check and create attribute-memoizing Hash whose default_proc will fetch
33
+ # and collate the data for a given KEY.
34
+ @@implantation rescue begin
35
+ @@implantation = Hash.new { |piles, key|
36
+ # Optionally limit search to top-level Module like `:const_defined?` with `inherit`
37
+ piles[key] = Set[key.molecule].merge(key.inherit ? key.molecule.ancestors : []).each_with_object(Hash.new) { |mod, pile|
38
+ mod.const_get(key.constant).each { |target, elements|
39
+ pile.update(target => elements) { |_key, existing, new| existing.merge(new) }
40
+ } rescue nil
41
+ }
42
+ }
43
+ end
44
+
45
+ # Generic entry-point for attribute-collation of a given constant
46
+ # over a given Molecule or Enumerable of Molecules.
47
+ def self.IMPLANTATION(constant, corpus = self.media_molecules)
48
+ (corpus.is_a?(Enumerable) ? corpus : Array[corpus]).map { |molecule|
49
+ KEY.new(molecule, constant)
50
+ }.each_with_object(Hash[]) { |key, piles|
51
+ # Hash#slice doesn't trigger the default_proc, so access each directly.
52
+ piles.store(key, @@implantation[key])
53
+ }.yield_self { |piles|
54
+ # Return just the data when we were given a single Molecule to search.
55
+ corpus.is_a?(Enumerable) ? piles : piles.shift[1]
56
+ }
57
+ end
58
+ end