sevgi-function 0.73.2 → 0.93.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54d8ec739ffb09b6af47ed063c7f623eec4d53f460da37145dde187d66afb0bd
4
- data.tar.gz: 73d6bcc926901e37b9cc5a34cab9ef36dc702e3465e04299fdd8a50ae98de2e9
3
+ metadata.gz: 060b3cdf233db4debfb3429c2d8663d94840ae00de70980124089f2d5d04c675
4
+ data.tar.gz: 2f80215534863a248171055a829ddcfa36ccf73013fbd430064b04fade3eeb5f
5
5
  SHA512:
6
- metadata.gz: bb79282e9492163de25d534c4c00e69d5e6e2f12043cfba825dd1dbc5d81cf6a2ca8e45e294802956b45ed825a25e07319507e9815d1246bb1723ecd43d29fd4
7
- data.tar.gz: 841d5ca22c243c504e5b64d50f16ba5ae9ff33e9cdf96300b517fe2c60e32b48fae7133cc2b248f81cc1d8c2b7ddf66f795439aa1c547155917608c8b6e1828d
6
+ metadata.gz: e4de61331c47e0007652e6c7548adf6de50e8d4ded0e1bc95c05dc9d7a0fc9929ee4ffdd3af3b3cd73eddc777495691c4952186e3f0f34390edcee062f0e907b
7
+ data.tar.gz: a36e58998da54d4c687e9266e1c58290af919ab3e4229627ab2d4c5a9b0ec4bd7a04a053ef8e8779ea3ef51268c1add54c75328475904562267d7073e683fa3f
data/README.md CHANGED
@@ -0,0 +1,5 @@
1
+ # Sevgi Function
2
+
3
+ Provides shared helper functions used by Sevgi components.
4
+
5
+ See the root [README](../README.md) and the documentation site for usage.
data/lib/sevgi/core.rb CHANGED
@@ -1,50 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sevgi
4
- # Constants
4
+ # Environment variable that asks command-line tools to re-raise captured errors.
5
5
  ENVVOMIT = "SEVGI_VOMIT"
6
+
7
+ # Default file extension for Sevgi scripts.
6
8
  EXTENSION = "sevgi"
7
9
 
8
- # Errors
9
10
  unless defined?(Error)
11
+ # Base error class for Sevgi failures.
10
12
  class Error < StandardError
13
+ # @overload call(*args, **kwargs, &block)
14
+ # Raises this error class.
15
+ # @param args [Array] positional arguments forwarded to the exception constructor
16
+ # @param kwargs [Hash] keyword arguments forwarded to the exception constructor
17
+ # @yield optional block forwarded to the exception constructor
18
+ # @yieldreturn [Object]
19
+ # @return [void]
20
+ # @raise [Sevgi::Error] always raises an instance of this class
11
21
  def self.call(*, **, &) = raise(self, *, **, &)
12
22
  end
13
23
  end
14
24
 
15
- # for incorrect API usage
25
+ unless defined?(self::MissingComponentError)
26
+ # Error raised when an optional Sevgi component is required but unavailable.
27
+ class MissingComponentError < Error
28
+ # @return [String] missing component name
29
+ attr_reader :component
30
+
31
+ # Builds a missing component error.
32
+ # @param component [String, Symbol] missing component name
33
+ # @return [void]
34
+ def initialize(component)
35
+ @component = component.to_s
36
+
37
+ super("\"#{component}\" required")
38
+ end
39
+ end
40
+ end
41
+
42
+ # Error raised for internal invariants and implementation paths that should be unreachable.
16
43
  PanicError = Class.new(Error) unless defined?(self::PanicError)
44
+
45
+ # Error raised for invalid public API usage.
17
46
  ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
18
47
 
19
- # Helpers
20
- # Copied from https://github.com/dry-rb/dry-core. All kudos to the original authors.
48
+ # Shared immutable empty array.
21
49
  EMPTY_ARRAY = [].freeze
50
+
51
+ # Shared immutable empty hash.
22
52
  EMPTY_HASH = {}.freeze
53
+
54
+ # Shared immutable empty options hash.
23
55
  EMPTY_OPTS = {}.freeze
56
+
57
+ # Shared empty string.
24
58
  EMPTY_STRING = ""
59
+
60
+ # Identity callable.
25
61
  IDENTITY = -> (x) { x }.freeze
26
62
 
63
+ # Sentinel object used to distinguish an omitted value from nil.
27
64
  Undefined = Object
28
65
  .new
29
66
  .tap do |undefined|
30
67
  const_set(:Self, -> { Undefined })
31
68
 
69
+ # Returns the sentinel name.
70
+ # @return [String]
32
71
  def undefined.to_s = "Undefined"
33
72
 
73
+ # Returns the sentinel inspection string.
74
+ # @return [String]
34
75
  def undefined.inspect = "Undefined"
35
76
 
77
+ # Resolves a value unless it is {Sevgi::Undefined}.
78
+ # @param x [Object] candidate value
79
+ # @param y [Object] fallback value
80
+ # @yield computes the fallback when both x and y are undefined
81
+ # @yieldreturn [Object]
82
+ # @return [Object] x, y, or the yielded fallback
36
83
  def undefined.default(x, y = self)
37
84
  return x unless equal?(x)
38
85
 
39
86
  equal?(y) ? yield : y
40
87
  end
41
88
 
89
+ # Maps a value unless it is {Sevgi::Undefined}.
90
+ # @param value [Object] candidate value
91
+ # @yield maps a defined value
92
+ # @yieldparam value [Object] the defined value
93
+ # @yieldreturn [Object]
94
+ # @return [Object] the sentinel or the mapped value
42
95
  def undefined.map(value) = equal?(value) ? self : yield(value)
43
96
 
97
+ # Returns the sentinel itself.
98
+ # @return [Sevgi::Undefined]
44
99
  def undefined.dup = self
45
100
 
101
+ # Returns the sentinel itself.
102
+ # @return [Sevgi::Undefined]
46
103
  def undefined.clone = self
47
104
 
105
+ # Returns the first argument that is not {Sevgi::Undefined}.
106
+ # @param args [Array<Object>] candidate values
107
+ # @return [Object, nil] first defined value, including nil, or nil when none exists
48
108
  def undefined.coalesce(*args) = args.find(Self) { |x| !equal?(x) }
49
109
  end
50
110
  .freeze
@@ -2,15 +2,46 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # ANSI color and style helpers for terminal output.
5
6
  module Color
7
+ # Wraps a string in the blue terminal style.
8
+ # @param string [Object] content to style
9
+ # @return [String]
6
10
  def blue(string) = "\e[1m\e[38;5;81m#{string}\e[0m"
11
+
12
+ # Wraps a string in the cyan terminal style.
13
+ # @param string [Object] content to style
14
+ # @return [String]
7
15
  def cyan(string) = "\e[1m\e[38;5;51m#{string}\e[0m"
16
+
17
+ # Wraps a string in the green terminal style.
18
+ # @param string [Object] content to style
19
+ # @return [String]
8
20
  def green(string) = "\e[1m\e[38;5;35m#{string}\e[0m"
21
+
22
+ # Wraps a string in the magenta terminal style.
23
+ # @param string [Object] content to style
24
+ # @return [String]
9
25
  def magenta(string) = "\e[1m\e[38;5;200m#{string}\e[0m"
26
+
27
+ # Wraps a string in the red terminal style.
28
+ # @param string [Object] content to style
29
+ # @return [String]
10
30
  def red(string) = "\e[1m\e[38;5;197m#{string}\e[0m"
31
+
32
+ # Wraps a string in the yellow terminal style.
33
+ # @param string [Object] content to style
34
+ # @return [String]
11
35
  def yellow(string) = "\e[1m\e[38;5;227m#{string}\e[0m"
12
36
 
37
+ # Wraps a string in the bold terminal style.
38
+ # @param string [Object] content to style
39
+ # @return [String]
13
40
  def bold(string) = "\e[1m#{string}\e[0m"
41
+
42
+ # Wraps a string in the dim terminal style.
43
+ # @param string [Object] content to style
44
+ # @return [String]
14
45
  def dim(string) = "\e[1m\e[2m#{string}\e[0m"
15
46
  end
16
47
 
@@ -5,7 +5,16 @@ require "fileutils"
5
5
 
6
6
  module Sevgi
7
7
  module Function
8
+ # File-system helpers used by build scripts and DSL support code.
8
9
  module File
10
+ # Checks whether a file would change if written with content.
11
+ # @param file [String] file path to compare
12
+ # @param content [String] proposed file content
13
+ # @yield optional normalization filter applied to both old and new content
14
+ # @yieldparam content [String] content to normalize
15
+ # @yieldreturn [String]
16
+ # @return [Boolean] true when the file is missing or content differs
17
+ # @raise [Errno::EACCES] when the file cannot be read
9
18
  def changed?(file, content, &filter)
10
19
  return true unless ::File.exist?(file)
11
20
 
@@ -15,6 +24,10 @@ module Sevgi
15
24
  Digest::SHA1.digest(old_content) != Digest::SHA1.digest(content)
16
25
  end
17
26
 
27
+ # Finds an existing file by exact path or by trying default extensions.
28
+ # @param file [String] file path or extensionless basename
29
+ # @param extensions [Array<String>] extensions to try when file has no extension
30
+ # @return [String, nil] matching file path, or nil when no file is found
18
31
  def existing(file, extensions)
19
32
  return file if ::File.exist?(file)
20
33
  return nil unless ::File.extname(file).empty?
@@ -23,27 +36,51 @@ module Sevgi
23
36
  extensions.map { |ext| "#{file}.#{ext}" }.detect { |file| ::File.exist?(file) }
24
37
  end
25
38
 
39
+ # Finds an existing file or raises.
40
+ # @param file [String] file path or extensionless basename
41
+ # @param extensions [Array<String>] extensions to try when file has no extension
42
+ # @return [String] matching file path
43
+ # @raise [Sevgi::ArgumentError] when no matching file exists
26
44
  def existing!(file, extensions)
27
45
  existing(file, extensions).tap do |found|
28
- raise ArgumentError, "No matching file(s) found: #{file}" unless found
46
+ ArgumentError.("No matching file(s) found: #{file}") unless found
29
47
  end
30
48
  end
31
49
 
32
- def existings(*files, extensions: [])
33
- {}.tap do |existings|
34
- files.compact.each { |file| existings[file] = existing(file, extensions) }
50
+ # Maps each non-nil input file to an existing path lookup result.
51
+ # @param files [Array<String, nil>] file paths or extensionless basenames
52
+ # @param extensions [Array<String>] extensions to try when a file has no extension
53
+ # @return [Hash{String => String, nil}] original file names mapped to found paths
54
+ def existing_map(*files, extensions: [])
55
+ {}.tap do |found|
56
+ files.compact.each { |file| found[file] = existing(file, extensions) }
35
57
  end
36
58
  end
37
59
 
38
- def existings!(...)
39
- existings = F.existings(...)
40
- missings = existings.select { |_, match| match.nil? }.keys
60
+ # @overload existing_map!(*files, extensions: [])
61
+ # Maps each non-nil input file to an existing path lookup result or raises.
62
+ # @param files [Array<String, nil>] file paths or extensionless basenames
63
+ # @param extensions [Array<String>] extensions to try when a file has no extension
64
+ # @return [Hash{String => String}] original file names mapped to found paths
65
+ # @raise [Sevgi::ArgumentError] when any requested file is missing
66
+ def existing_map!(...)
67
+ found = F.existing_map(...)
68
+ missings = found.select { |_, match| match.nil? }.keys
41
69
 
42
- raise ArgumentError, "No matching file(s) found: #{missings.join(", ")}" unless missings.empty?
70
+ ArgumentError.("No matching file(s) found: #{missings.join(", ")}") unless missings.empty?
43
71
 
44
- existings
72
+ found
45
73
  end
46
74
 
75
+ # Writes content to a file when it changed, or prints to stdout without a path.
76
+ # @param content [String] output content
77
+ # @param paths [Array<String>] path components for the output file
78
+ # @yield optional normalization filter used for change detection
79
+ # @yieldparam content [String] old or new content
80
+ # @yieldreturn [String]
81
+ # @return [String, nil] expanded file path when written, otherwise nil
82
+ # @raise [Errno::EACCES] when the file cannot be read or written
83
+ # @raise [Errno::ENOENT] when the parent directory does not exist
47
84
  def out(content, *paths, &filter)
48
85
  if paths.empty?
49
86
  ::Kernel.puts(content)
@@ -57,13 +94,20 @@ module Sevgi
57
94
  end
58
95
  end
59
96
 
97
+ # Adds a default extension when a path has no extension.
98
+ # @param file [String] file path
99
+ # @param default_extension [String] extension to append without a leading dot
100
+ # @return [String] qualified file path
60
101
  def qualify(file, default_extension)
61
102
  return file unless ::File.extname(file).empty?
62
103
 
63
104
  "#{file}.#{default_extension}"
64
105
  end
65
106
 
66
- # Adapted from ActiveSupport
107
+ # Replaces or removes the extension on a path.
108
+ # @param ext [String, nil] replacement extension, without or with a leading dot
109
+ # @param paths [Array<String>] path components
110
+ # @return [String] path with the replacement extension
67
111
  def subext(ext, *paths)
68
112
  path = ::File.join(*paths)
69
113
 
@@ -75,6 +119,10 @@ module Sevgi
75
119
  Pathname.new(path).sub_ext(ext).to_s
76
120
  end
77
121
 
122
+ # Creates a file and any missing parent directories.
123
+ # @param paths [Array<String>] path components for the file
124
+ # @return [String] touched file path
125
+ # @raise [Errno::EACCES] when the file or parent directory cannot be created
78
126
  def touch(*paths)
79
127
  ::File.join(*paths).tap do |path|
80
128
  ::FileUtils.mkdir_p(::File.dirname(path))
@@ -2,21 +2,48 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # Locates one of several candidate files by walking upward from a start directory.
5
6
  class Locate
7
+ # @overload call(paths, start = Dir.pwd, exclude: nil, &block)
8
+ # Builds a locator and runs it.
9
+ # @param paths [Array<String>, String] candidate file paths
10
+ # @param start [String] directory where lookup starts
11
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup
12
+ # @yield optional matcher used instead of file existence checks
13
+ # @yieldparam path [String] candidate path
14
+ # @yieldreturn [Boolean]
15
+ # @return [Sevgi::Function::Locate::Location, nil] found location, or nil
6
16
  def self.call(*, **, &block) = new(*, **).call(&block)
7
17
 
8
18
  Location = Data.define(:file, :slug, :dir)
9
19
 
10
20
  private_constant :Location
11
21
 
22
+ # @!attribute [r] paths
23
+ # @return [Array<String>] candidate paths
24
+ # @!attribute [r] start
25
+ # @return [String] start directory
26
+ # @!attribute [r] exclude
27
+ # @return [Array<String>, nil] expanded paths ignored during lookup
12
28
  attr_reader :paths, :start, :exclude
13
29
 
30
+ # Builds an upward file locator.
31
+ # @param paths [Array<String>, String] candidate file paths
32
+ # @param start [String] directory where lookup starts
33
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup
34
+ # @return [void]
14
35
  def initialize(paths, start = ::Dir.pwd, exclude: nil)
15
36
  @paths = Array(paths)
16
37
  @start = start
17
38
  @exclude = [*exclude].map { ::File.expand_path(it) } unless exclude.nil?
18
39
  end
19
40
 
41
+ # Runs the upward lookup.
42
+ # @yield optional matcher used instead of file existence checks
43
+ # @yieldparam path [String] candidate path
44
+ # @yieldreturn [Boolean]
45
+ # @return [Sevgi::Function::Locate::Location, nil] found location, or nil
46
+ # @raise [Errno::ENOENT] when the start directory cannot be entered
20
47
  def call(&block)
21
48
  origin = ::Dir.pwd
22
49
  ::Dir.chdir(start)
@@ -46,9 +73,16 @@ module Sevgi
46
73
  end
47
74
  end
48
75
 
76
+ # Locates a Sevgi-related file by walking upward from a start directory.
77
+ # @param filename [String] file name or extensionless basename
78
+ # @param start [String] directory where lookup starts
79
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup
80
+ # @param extension [String] default extension added before lookup
81
+ # @return [Sevgi::Function::Locate::Location] found location
82
+ # @raise [Sevgi::Error] when no matching file exists
49
83
  def self.locate(filename, start, exclude: nil, extension: EXTENSION)
50
84
  Locate.(F.qualify(filename, extension), start, exclude:).tap do |path|
51
- raise Error, "Cannot load a file matching: #{filename}" unless path
85
+ Error.("Cannot load a file matching: #{filename}") unless path
52
86
  end
53
87
  end
54
88
  end
@@ -2,51 +2,165 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # Numeric and trigonometric helpers used by geometry and DSL code.
5
6
  module Math
6
- @precision = PRECISION = 6
7
+ # Default decimal precision used by approximate comparisons.
8
+ PRECISION = 6
7
9
 
8
- class << self
9
- attr_accessor :precision
10
+ PRECISION_KEY = :sevgi_function_math_precision
11
+
12
+ private_constant :PRECISION_KEY
13
+
14
+ # Returns the current thread's default numeric precision.
15
+ # @return [Integer] current thread precision, or {PRECISION} when no override is set
16
+ def self.precision
17
+ precision = Thread.current.thread_variable_get(PRECISION_KEY)
18
+
19
+ precision.nil? ? PRECISION : precision
20
+ end
21
+
22
+ # Sets or clears the current thread's default numeric precision.
23
+ # @param precision [Integer, nil] precision override, or nil to return to {PRECISION}
24
+ # @return [Integer, nil] assigned precision
25
+ def self.precision=(precision)
26
+ Thread.current.thread_variable_set(PRECISION_KEY, precision)
10
27
  end
11
28
 
29
+ # Returns the inverse cosine in degrees.
30
+ # @param value [Numeric] cosine value
31
+ # @return [Float]
32
+ # @raise [Math::DomainError] when value is outside -1..1
12
33
  def acos(value) = to_degrees(::Math.acos(value))
13
34
 
35
+ # Returns the inverse cotangent in degrees.
36
+ # @param value [Numeric] cotangent value
37
+ # @return [Float]
14
38
  def acot(value) = 90.0 - to_degrees(::Math.atan(value))
15
39
 
16
- def approx(float, precision = nil) = float.round(precision || Function::Math.precision)
40
+ # Rounds a float with an explicit or thread-local precision.
41
+ # @param float [Numeric] value to round
42
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
43
+ # @return [Numeric] rounded value
44
+ # @raise [TypeError] when float cannot be rounded
45
+ def approx(float, precision = nil)
46
+ float.round(precision.nil? ? Function::Math.precision : precision)
47
+ end
17
48
 
49
+ # Returns the inverse sine in degrees.
50
+ # @param value [Numeric] sine value
51
+ # @return [Float]
52
+ # @raise [Math::DomainError] when value is outside -1..1
18
53
  def asin(value) = to_degrees(::Math.asin(value))
19
54
 
55
+ # Returns the inverse tangent in degrees.
56
+ # @param value [Numeric] tangent value
57
+ # @return [Float]
20
58
  def atan(value) = to_degrees(::Math.atan(value))
21
59
 
60
+ # Returns the quadrant-aware inverse tangent in degrees.
61
+ # @param y [Numeric] y component
62
+ # @param x [Numeric] x component
63
+ # @return [Float]
22
64
  def atan2(y, x) = to_degrees(::Math.atan2(y, x))
23
65
 
66
+ # Returns the cosine of an angle expressed in degrees.
67
+ # @param degrees [Numeric] angle in degrees
68
+ # @return [Float]
24
69
  def cos(degrees) = ::Math.cos(to_radians(degrees))
25
70
 
71
+ # Returns the cotangent of an angle expressed in degrees.
72
+ # @param degrees [Numeric] angle in degrees
73
+ # @return [Float]
26
74
  def cot(degrees) = 1.0 / ::Math.tan(to_radians(degrees))
27
75
 
76
+ # Counts complete divisions in a length.
77
+ # @param length [Numeric] total length
78
+ # @param division [Numeric] division size
79
+ # @return [Integer]
80
+ # @raise [ZeroDivisionError] when division is zero
28
81
  def count(length, division) = (length / division.to_f).to_i
29
82
 
83
+ # Compares two numeric values after approximate rounding.
84
+ # @param left [Numeric] left operand
85
+ # @param right [Numeric] right operand
86
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
87
+ # @return [Boolean]
30
88
  def eq?(left, right, precision: nil) = approx(left, precision) == approx(right, precision)
31
89
 
90
+ # Checks whether the rounded left operand is greater than or equal to the rounded right operand.
91
+ # @param left [Numeric] left operand
92
+ # @param right [Numeric] right operand
93
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
94
+ # @return [Boolean]
32
95
  def ge?(left, right, precision: nil) = approx(left, precision) >= approx(right, precision)
33
96
 
97
+ # Checks whether the rounded left operand is greater than the rounded right operand.
98
+ # @param left [Numeric] left operand
99
+ # @param right [Numeric] right operand
100
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
101
+ # @return [Boolean]
34
102
  def gt?(left, right, precision: nil) = approx(left, precision) > approx(right, precision)
35
103
 
104
+ # Checks whether the rounded left operand is less than or equal to the rounded right operand.
105
+ # @param left [Numeric] left operand
106
+ # @param right [Numeric] right operand
107
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
108
+ # @return [Boolean]
36
109
  def le?(left, right, precision: nil) = approx(left, precision) <= approx(right, precision)
37
110
 
111
+ # Checks whether the rounded left operand is less than the rounded right operand.
112
+ # @param left [Numeric] left operand
113
+ # @param right [Numeric] right operand
114
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
115
+ # @return [Boolean]
38
116
  def lt?(left, right, precision: nil) = approx(left, precision) < approx(right, precision)
39
117
 
118
+ # Rounds a value only when precision is present.
119
+ # @param float [Numeric] value to round
120
+ # @param precision [Integer, nil] explicit precision, or nil to return float unchanged
121
+ # @return [Numeric]
40
122
  def round(float, precision) = precision ? float.round(precision) : float
41
123
 
124
+ # Returns the sine of an angle expressed in degrees.
125
+ # @param degrees [Numeric] angle in degrees
126
+ # @return [Float]
42
127
  def sin(degrees) = ::Math.sin(to_radians(degrees))
43
128
 
129
+ # Returns the tangent of an angle expressed in degrees.
130
+ # @param degrees [Numeric] angle in degrees
131
+ # @return [Float]
44
132
  def tan(degrees) = ::Math.tan(to_radians(degrees))
45
133
 
134
+ # Converts radians to degrees.
135
+ # @param radians [Numeric] angle in radians
136
+ # @return [Float]
46
137
  def to_degrees(radians) = radians.to_f * 180 / ::Math::PI
47
138
 
139
+ # Converts degrees to radians.
140
+ # @param degrees [Numeric] angle in degrees
141
+ # @return [Float]
48
142
  def to_radians(degrees) = degrees.to_f / 180 * ::Math::PI
49
143
 
144
+ # Runs a block with a current-thread precision override.
145
+ # @param precision [Integer, nil] scoped precision, or nil to use {PRECISION}
146
+ # @yield block executed with the scoped precision
147
+ # @yieldreturn [Object]
148
+ # @return [Object] block return value
149
+ # @raise [Sevgi::ArgumentError] when no block is given
150
+ def with_precision(precision, &block)
151
+ ArgumentError.("Block required") unless block
152
+
153
+ previous = Thread.current.thread_variable_get(PRECISION_KEY)
154
+ Function::Math.precision = precision
155
+ block.call
156
+ ensure
157
+ Function::Math.precision = previous if block
158
+ end
159
+
160
+ # Checks whether a value is approximately zero.
161
+ # @param value [Numeric] value to check
162
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
163
+ # @return [Boolean]
50
164
  def zero?(value, precision: nil) = eq?(value, 0.0, precision:)
51
165
  end
52
166
 
@@ -4,49 +4,99 @@ require "open3"
4
4
 
5
5
  module Sevgi
6
6
  module Function
7
+ # Shell execution helpers and executable lookup utilities.
7
8
  module Shell
9
+ # Checks whether a program exists in PATH.
10
+ # @param program [Object] program name
11
+ # @return [Boolean] true when an executable with this name is found
8
12
  def executable?(program)
13
+ program = program.to_s
14
+ return false if program.empty?
15
+
9
16
  executable_cache.fetch(program) do
10
- executable_cache[program] = ENV["PATH"].split(::File::PATH_SEPARATOR).any? do |dir|
17
+ executable_cache[program] = ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR).any? do |dir|
11
18
  ::File.executable?(::File.join(dir, program))
12
19
  end
13
20
  end
14
21
  end
15
22
 
23
+ # Requires the first command argument to name an executable program.
24
+ # @param args [Array<Object>] command arguments
25
+ # @return [nil]
26
+ # @raise [Sevgi::Error] when the program cannot be found in PATH
16
27
  def executable!(*args)
17
- program = args.first.split.first
18
- raise "Missing executable: #{program}" unless executable?(program)
28
+ program = args.first.to_s.split.first
29
+ Error.("Missing executable: #{program}") unless executable?(program)
19
30
  end
20
31
 
32
+ # Result object returned by shell commands.
21
33
  Result = Struct.new(:args, :outs, :errs, :exit_code) do
34
+ # @!attribute [r] args
35
+ # @return [Array<String>] command arguments
36
+ # @!attribute [r] outs
37
+ # @return [Array<String>] captured stdout lines
38
+ # @!attribute [r] errs
39
+ # @return [Array<String>] captured stderr lines
40
+ # @!attribute [r] exit_code
41
+ # @return [Integer, nil] process exit code
42
+
43
+ # Returns stdout, a separator, and stderr as one string.
44
+ # @return [String]
22
45
  def all = [*outs, "\n\n", *errs].join("\n").strip
46
+
47
+ # Returns the command as a shell-like display string.
48
+ # @return [String]
23
49
  def cmd = args.join(" ")
50
+
51
+ # Returns captured stderr as a string.
52
+ # @return [String]
24
53
  def err = errs.join("\n")
54
+
55
+ # Reports whether the command failed.
56
+ # @return [Boolean]
25
57
  def notok? = !ok?
58
+
59
+ # Reports whether the command exited successfully.
60
+ # @return [Boolean]
26
61
  def ok? = exit_code&.zero?
62
+
63
+ # Returns captured stdout as a string.
64
+ # @return [String]
27
65
  def out = outs.join("\n")
66
+
67
+ # Returns the first stdout line.
68
+ # @return [String, nil]
28
69
  def outline = outs.first
29
70
 
71
+ # Builds a successful empty result.
72
+ # @return [Sevgi::Function::Shell::Result]
30
73
  def self.dummy = new([], [], [], 0)
31
74
  end
32
75
 
33
- # Adapted to popen3 from github.com/mina-deploy/mina
76
+ # Runs shell commands and captures stdout, stderr, and exit status.
77
+ # @api private
34
78
  class Runner
79
+ # Creates a shell runner.
80
+ # @return [void]
35
81
  def initialize
36
82
  @coathooks = 0
37
83
  end
38
84
 
39
- def call(*args, &block)
85
+ # Runs a command and captures its output.
86
+ # @param args [Array<String>] command and arguments
87
+ # @yield optional content writer for stdin
88
+ # @yieldreturn [String, nil]
89
+ # @return [Sevgi::Function::Shell::Result]
90
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
91
+ def call(*args, &input)
40
92
  return Result.dummy if args.empty?
41
93
 
42
94
  outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
43
- if block
44
- input = block.call
45
- stdin.write(block.call) if input
46
- stdin.close
47
- end
95
+ content = input.call if input
96
+ stdin.write(content) if content
97
+ stdin.close
48
98
 
49
- block(stdout, stderr, wait_thread)
99
+ capture(stdout, stderr, wait_thread)
50
100
  end
51
101
 
52
102
  Result.new(args, outs, errs, status.exitstatus)
@@ -54,14 +104,16 @@ module Sevgi
54
104
 
55
105
  private
56
106
 
57
- def block(stdout, stderr, wait_thread)
107
+ def capture(stdout, stderr, wait_thread)
58
108
  # Handle `^C`
59
- trap("INT") { handle_sigint(wait_thread.pid) }
109
+ previous = trap("INT") { handle_sigint(wait_thread.pid) }
60
110
 
61
- outs = stdout.readlines.map(&:chomp)
62
- errs = stderr.readlines.map(&:chomp)
111
+ outs = Thread.new { stdout.readlines.map(&:chomp) }
112
+ errs = Thread.new { stderr.readlines.map(&:chomp) }
63
113
 
64
- [outs, errs, wait_thread.value]
114
+ [outs.value, errs.value, wait_thread.value]
115
+ ensure
116
+ trap("INT", previous) if previous
65
117
  end
66
118
 
67
119
  def handle_sigint(pid)
@@ -80,8 +132,22 @@ module Sevgi
80
132
  end
81
133
  end
82
134
 
135
+ # @overload sh(*args, &block)
136
+ # Runs a command and captures stdout, stderr, and exit status.
137
+ # @param args [Array<String>] command and arguments
138
+ # @yield optional content writer for stdin
139
+ # @yieldreturn [String, nil]
140
+ # @return [Sevgi::Function::Shell::Result]
141
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
83
142
  def sh(...) = Runner.new.(...)
84
143
 
144
+ # Runs a command, requiring both executable lookup and successful exit status.
145
+ # @param args [Array<String>] command and arguments
146
+ # @yield optional content writer for stdin
147
+ # @yieldreturn [String, nil]
148
+ # @return [Sevgi::Function::Shell::Result]
149
+ # @raise [Sevgi::Error] when the executable is missing or the command fails
150
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
85
151
  def sh!(*args, &block)
86
152
  executable!(*args) unless args.empty?
87
153
 
@@ -90,7 +156,7 @@ module Sevgi
90
156
  warn(result.err)
91
157
  warn("")
92
158
 
93
- raise "Command failed: #{result.cmd}"
159
+ Error.("Command failed: #{result.cmd}")
94
160
  end
95
161
  end
96
162
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # String helpers used by generated names and user-facing text.
5
6
  module String
7
+ # Returns the final constant name segment from a module path.
8
+ # @param path [Object] module, class, or string-like path
9
+ # @return [String]
6
10
  def demodulize(path)
7
11
  path = path.to_s
8
12
  if (i = path.rindex("::"))
@@ -15,8 +19,9 @@ module Sevgi
15
19
 
16
20
  extend String
17
21
 
18
- # Simplified from https://github.com/rails/rails/blob/main/activesupport/lib/active_support/inflector/inflections.rb
22
+ # Lightweight English pluralization helper.
19
23
  module Pluralize
24
+ # Words that should not be pluralized.
20
25
  UNCOUNTABLES = Hash[
21
26
  *%w[
22
27
  sheep
@@ -32,6 +37,7 @@ module Sevgi
32
37
  ]
33
38
  .freeze
34
39
 
40
+ # Singular-to-plural forms that do not follow suffix rules.
35
41
  IRREGULARS = Hash[
36
42
  *%w[
37
43
  child
@@ -54,8 +60,10 @@ module Sevgi
54
60
  ]
55
61
  .freeze
56
62
 
63
+ # Plural forms already accepted as plural.
57
64
  PLURALS = IRREGULARS.invert.freeze
58
65
 
66
+ # Ordered suffix replacement rules.
59
67
  RULES = [
60
68
  [/(quiz)$/i, "\\1zes"],
61
69
  [/^(oxen)$/i, "\\1"],
@@ -80,11 +88,14 @@ module Sevgi
80
88
  [/$/, "s"]
81
89
  ].freeze
82
90
 
83
- # Pluralize.('post') # => "posts"
84
- # Pluralize.('octopus') # => "octopi"
85
- # Pluralize.('sheep') # => "sheep"
86
- # Pluralize.('words') # => "words"
87
- # Pluralize.('CamelOctopus') # => "CamelOctopi"
91
+ # Pluralizes an English word using a small built-in rule set.
92
+ # @param word [Object] word to pluralize
93
+ # @return [String]
94
+ # @example
95
+ # F.pluralize("post") # => "posts"
96
+ # F.pluralize("octopus") # => "octopi"
97
+ # F.pluralize("sheep") # => "sheep"
98
+ # F.pluralize("CamelOctopus") # => "CamelOctopi"
88
99
  def pluralize(word)
89
100
  result = word.to_s.dup
90
101
 
@@ -2,11 +2,33 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # Small terminal status helpers used by build and release tasks.
5
6
  module UI
7
+ # Reports an in-progress status message.
8
+ # @param message [Object] status message
9
+ # @return [nil]
6
10
  def do(message) = warn("#{cyan("···")} #{bold(message)}")
11
+
12
+ # Reports an optional neutral status message unless SILENT is set.
13
+ # @param message [Object] status message
14
+ # @return [nil]
7
15
  def mayok(message) = (warn(" #{dim("·")} #{dim(message)}") unless ENV["SILENT"])
16
+
17
+ # Reports a failure status message.
18
+ # @param message [Object] status message
19
+ # @return [nil]
8
20
  def notok(message) = warn(" #{red("✗")} #{bold(message)}")
21
+
22
+ # Reports a success status message.
23
+ # @param message [Object] status message
24
+ # @return [nil]
9
25
  def ok(message) = warn(" #{cyan("✓")} #{bold(message)}")
26
+
27
+ # Reports a status message according to the yielded value.
28
+ # @param message [Object] status message
29
+ # @yield computes the status value
30
+ # @yieldreturn [Object]
31
+ # @return [Object] yielded value
10
32
  def ui(message) = yield.tap { it ? ok(message) : notok(message) }
11
33
  end
12
34
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
- VERSION = "0.73.2"
5
+ # Current version of the Sevgi function gem.
6
+ VERSION = "0.93.1"
6
7
  end
7
8
  end
@@ -13,5 +13,13 @@ require_relative "function/ui"
13
13
  require_relative "function/version"
14
14
 
15
15
  module Sevgi
16
+ # Shared helper namespace used directly as `Sevgi::Function` and through {Sevgi::F}.
17
+ #
18
+ # @example Use helper methods through the public alias
19
+ # F.pluralize("axis")
20
+ module Function
21
+ end
22
+
23
+ # Public alias for the shared function helper namespace.
16
24
  F = Function unless defined?(F)
17
25
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-function
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.73.2
4
+ version: 0.93.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş