sevgi-function 0.73.2 → 0.94.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54d8ec739ffb09b6af47ed063c7f623eec4d53f460da37145dde187d66afb0bd
4
- data.tar.gz: 73d6bcc926901e37b9cc5a34cab9ef36dc702e3465e04299fdd8a50ae98de2e9
3
+ metadata.gz: 7afe91e24bcfcbc6b2e398838f8cf9cce6fdb97b9c7b7cded831a6f892c75280
4
+ data.tar.gz: 505afa54ddc3e02573ed7169ca6aa01f14a90bd3c57d41bafe0cb87bb7b47e46
5
5
  SHA512:
6
- metadata.gz: bb79282e9492163de25d534c4c00e69d5e6e2f12043cfba825dd1dbc5d81cf6a2ca8e45e294802956b45ed825a25e07319507e9815d1246bb1723ecd43d29fd4
7
- data.tar.gz: 841d5ca22c243c504e5b64d50f16ba5ae9ff33e9cdf96300b517fe2c60e32b48fae7133cc2b248f81cc1d8c2b7ddf66f795439aa1c547155917608c8b6e1828d
6
+ metadata.gz: dd9066dc9cc025ff15122265b66ace4861a62b150a10ac72addd1255bf3223337cd1ad3c6e0259423abc184a98db97cb0f5c0e0769224240e20db8ae8654ee41
7
+ data.tar.gz: 0d47888b1cce2c39fba38c09ead6044401908ae48d789ff596ee0cb7055c2225cab69b094803c84a8a8d1737d4d2a3268e1f769154e9770c55c01e9813f6160e
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Function follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Function is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -0,0 +1,36 @@
1
+ # Sevgi Function
2
+
3
+ Shared helper functions used by Sevgi components.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-function
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/function"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ Sevgi::F.eq?(0.1 + 0.2, 0.3, precision: 12)
21
+ ```
22
+
23
+ ## Ruby compatibility
24
+
25
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
26
+
27
+ ## Native prerequisites
28
+
29
+ None beyond Ruby and this gem's Ruby dependencies.
30
+
31
+ ## Links
32
+
33
+ - Documentation: https://sevgi.roktas.dev
34
+ - API documentation: https://www.rubydoc.info/gems/sevgi-function
35
+ - Source: https://github.com/roktas/sevgi/tree/main/function
36
+ - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/lib/sevgi/core.rb CHANGED
@@ -1,50 +1,95 @@
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)
17
- ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
18
44
 
19
- # Helpers
20
- # Copied from https://github.com/dry-rb/dry-core. All kudos to the original authors.
21
- EMPTY_ARRAY = [].freeze
22
- EMPTY_HASH = {}.freeze
23
- EMPTY_OPTS = {}.freeze
24
- EMPTY_STRING = ""
25
- IDENTITY = -> (x) { x }.freeze
45
+ # Error raised for invalid public API usage.
46
+ ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
26
47
 
48
+ # Sentinel object used to distinguish an omitted value from nil.
27
49
  Undefined = Object
28
50
  .new
29
51
  .tap do |undefined|
30
52
  const_set(:Self, -> { Undefined })
31
53
 
54
+ # Returns the sentinel name.
55
+ # @return [String]
32
56
  def undefined.to_s = "Undefined"
33
57
 
58
+ # Returns the sentinel inspection string.
59
+ # @return [String]
34
60
  def undefined.inspect = "Undefined"
35
61
 
62
+ # Resolves a value unless it is {Sevgi::Undefined}.
63
+ # @param x [Object] candidate value
64
+ # @param y [Object] fallback value
65
+ # @yield computes the fallback when both x and y are undefined
66
+ # @yieldreturn [Object]
67
+ # @return [Object] x, y, or the yielded fallback
36
68
  def undefined.default(x, y = self)
37
69
  return x unless equal?(x)
38
70
 
39
71
  equal?(y) ? yield : y
40
72
  end
41
73
 
74
+ # Maps a value unless it is {Sevgi::Undefined}.
75
+ # @param value [Object] candidate value
76
+ # @yield maps a defined value
77
+ # @yieldparam value [Object] the defined value
78
+ # @yieldreturn [Object]
79
+ # @return [Object] the sentinel or the mapped value
42
80
  def undefined.map(value) = equal?(value) ? self : yield(value)
43
81
 
82
+ # Returns the sentinel itself.
83
+ # @return [Sevgi::Undefined]
44
84
  def undefined.dup = self
45
85
 
86
+ # Returns the sentinel itself.
87
+ # @return [Sevgi::Undefined]
46
88
  def undefined.clone = self
47
89
 
90
+ # Returns the first argument that is not {Sevgi::Undefined}.
91
+ # @param args [Array<Object>] candidate values
92
+ # @return [Object, nil] first defined value, including nil, or nil when none exists
48
93
  def undefined.coalesce(*args) = args.find(Self) { |x| !equal?(x) }
49
94
  end
50
95
  .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,53 +2,111 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # Found file location returned by locate helpers.
6
+ #
7
+ # @!attribute [r] file
8
+ # @return [String] absolute matching file path
9
+ # @!attribute [r] slug
10
+ # @return [String] candidate path that matched
11
+ # @!attribute [r] dir
12
+ # @return [String] directory where the match was found
13
+ Location = Data.define(:file, :slug, :dir)
14
+
15
+ # Locates one of several candidate files by walking upward from a start directory.
5
16
  class Locate
17
+ # @overload call(paths, start = Dir.pwd, exclude: nil, &block)
18
+ # Builds a locator and runs it.
19
+ # @param paths [Array<String>, String] candidate file paths
20
+ # @param start [String] directory where lookup starts
21
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup
22
+ # @yield optional matcher used instead of file existence checks
23
+ # @yieldparam path [String] candidate path
24
+ # @yieldreturn [Boolean]
25
+ # @return [Sevgi::Function::Location, nil] found location, or nil
6
26
  def self.call(*, **, &block) = new(*, **).call(&block)
7
27
 
8
- Location = Data.define(:file, :slug, :dir)
9
-
10
- private_constant :Location
11
-
28
+ # @!attribute [r] paths
29
+ # @return [Array<String>] candidate paths
30
+ # @!attribute [r] start
31
+ # @return [String] absolute start directory
32
+ # @!attribute [r] exclude
33
+ # @return [Array<String>, nil] expanded paths ignored during lookup
12
34
  attr_reader :paths, :start, :exclude
13
35
 
36
+ # Builds an upward file locator.
37
+ # @param paths [Array<String>, String] candidate file paths
38
+ # @param start [String] directory where lookup starts, expanded without changing process cwd
39
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup after absolute expansion
40
+ # @return [void]
14
41
  def initialize(paths, start = ::Dir.pwd, exclude: nil)
15
42
  @paths = Array(paths)
16
- @start = start
43
+ @start = ::File.expand_path(start)
17
44
  @exclude = [*exclude].map { ::File.expand_path(it) } unless exclude.nil?
18
45
  end
19
46
 
47
+ # Runs the upward lookup.
48
+ # @yield optional matcher used instead of file existence checks
49
+ # @yieldparam path [String] absolute candidate path
50
+ # @yieldreturn [Boolean]
51
+ # @return [Sevgi::Function::Location, nil] found location, or nil
52
+ # @raise [Errno::ENOENT] when the start directory does not exist
53
+ # @raise [Errno::ENOTDIR] when the start path is not a directory
20
54
  def call(&block)
21
- origin = ::Dir.pwd
22
- ::Dir.chdir(start)
55
+ validate_start!
23
56
 
24
- here = ::Dir.pwd
25
- until (found = match(&block))
26
- ::Dir.chdir("..")
27
- ::Dir.pwd == here ? return : here = ::Dir.pwd
28
- end
57
+ each_parent(start) do |here|
58
+ next unless (found = match(here, &block))
59
+
60
+ slug, file = found
29
61
 
30
- Location[::File.expand_path(found, here), found, here]
31
- ensure
32
- ::Dir.chdir(origin)
62
+ return Location[file, slug, here]
63
+ end
33
64
  end
34
65
 
35
66
  private
36
67
 
37
- def match(&block)
38
- finder = block ||
39
- if exclude.nil?
40
- proc { |path| ::File.exist?(path) }
41
- else
42
- proc { |path| !exclude.include?(::File.expand_path(path)) && ::File.exist?(path) }
43
- end
68
+ def each_parent(here)
69
+ loop do
70
+ yield here
71
+
72
+ parent = ::File.dirname(here)
73
+ break if parent == here
74
+
75
+ here = parent
76
+ end
77
+ end
78
+
79
+ def excluded?(candidate)
80
+ exclude&.include?(candidate)
81
+ end
82
+
83
+ def match(here, &block)
84
+ paths.each do |path|
85
+ candidate = ::File.expand_path(path, here)
86
+ next if !block && excluded?(candidate)
87
+
88
+ return [path, candidate] if (block || proc { ::File.exist?(it) }).call(candidate)
89
+ end
90
+
91
+ nil
92
+ end
44
93
 
45
- paths.find { finder.call(it) }
94
+ def validate_start!
95
+ raise ::Errno::ENOENT, start unless ::File.exist?(start)
96
+ raise ::Errno::ENOTDIR, start unless ::File.directory?(start)
46
97
  end
47
98
  end
48
99
 
100
+ # Locates a Sevgi-related file by walking upward from a start directory.
101
+ # @param filename [String] file name or extensionless basename
102
+ # @param start [String] directory where lookup starts
103
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup
104
+ # @param extension [String] default extension added before lookup
105
+ # @return [Sevgi::Function::Location] found location
106
+ # @raise [Sevgi::Error] when no matching file exists
49
107
  def self.locate(filename, start, exclude: nil, extension: EXTENSION)
50
108
  Locate.(F.qualify(filename, extension), start, exclude:).tap do |path|
51
- raise Error, "Cannot load a file matching: #{filename}" unless path
109
+ Error.("Cannot load a file matching: #{filename}") unless path
52
110
  end
53
111
  end
54
112
  end
@@ -2,51 +2,170 @@
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
 
28
- def count(length, division) = (length / division.to_f).to_i
76
+ # Counts complete divisions in a length.
77
+ # @param length [Numeric] total length
78
+ # @param division [Numeric] division size
79
+ # @return [Integer]
80
+ # @raise [Sevgi::ArgumentError] when division is zero
81
+ def count(length, division)
82
+ divisor = division.to_f
83
+ ArgumentError.("Division must not be zero") if divisor.zero?
84
+
85
+ (length / divisor).to_i
86
+ end
29
87
 
88
+ # Compares two numeric values after approximate rounding.
89
+ # @param left [Numeric] left operand
90
+ # @param right [Numeric] right operand
91
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
92
+ # @return [Boolean]
30
93
  def eq?(left, right, precision: nil) = approx(left, precision) == approx(right, precision)
31
94
 
95
+ # Checks whether the rounded left operand is greater than or equal to the rounded right operand.
96
+ # @param left [Numeric] left operand
97
+ # @param right [Numeric] right operand
98
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
99
+ # @return [Boolean]
32
100
  def ge?(left, right, precision: nil) = approx(left, precision) >= approx(right, precision)
33
101
 
102
+ # Checks whether the rounded left operand is greater than the rounded right operand.
103
+ # @param left [Numeric] left operand
104
+ # @param right [Numeric] right operand
105
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
106
+ # @return [Boolean]
34
107
  def gt?(left, right, precision: nil) = approx(left, precision) > approx(right, precision)
35
108
 
109
+ # Checks whether the rounded left operand is less than or equal to the rounded right operand.
110
+ # @param left [Numeric] left operand
111
+ # @param right [Numeric] right operand
112
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
113
+ # @return [Boolean]
36
114
  def le?(left, right, precision: nil) = approx(left, precision) <= approx(right, precision)
37
115
 
116
+ # Checks whether the rounded left operand is less than the rounded right operand.
117
+ # @param left [Numeric] left operand
118
+ # @param right [Numeric] right operand
119
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
120
+ # @return [Boolean]
38
121
  def lt?(left, right, precision: nil) = approx(left, precision) < approx(right, precision)
39
122
 
123
+ # Rounds a value only when precision is present.
124
+ # @param float [Numeric] value to round
125
+ # @param precision [Integer, nil] explicit precision, or nil to return float unchanged
126
+ # @return [Numeric]
40
127
  def round(float, precision) = precision ? float.round(precision) : float
41
128
 
129
+ # Returns the sine of an angle expressed in degrees.
130
+ # @param degrees [Numeric] angle in degrees
131
+ # @return [Float]
42
132
  def sin(degrees) = ::Math.sin(to_radians(degrees))
43
133
 
134
+ # Returns the tangent of an angle expressed in degrees.
135
+ # @param degrees [Numeric] angle in degrees
136
+ # @return [Float]
44
137
  def tan(degrees) = ::Math.tan(to_radians(degrees))
45
138
 
139
+ # Converts radians to degrees.
140
+ # @param radians [Numeric] angle in radians
141
+ # @return [Float]
46
142
  def to_degrees(radians) = radians.to_f * 180 / ::Math::PI
47
143
 
144
+ # Converts degrees to radians.
145
+ # @param degrees [Numeric] angle in degrees
146
+ # @return [Float]
48
147
  def to_radians(degrees) = degrees.to_f / 180 * ::Math::PI
49
148
 
149
+ # Runs a block with a current-thread precision override.
150
+ # @param precision [Integer, nil] scoped precision, or nil to use {PRECISION}
151
+ # @yield block executed with the scoped precision
152
+ # @yieldreturn [Object]
153
+ # @return [Object] block return value
154
+ # @raise [Sevgi::ArgumentError] when no block is given
155
+ def with_precision(precision, &block)
156
+ ArgumentError.("Block required") unless block
157
+
158
+ previous = Thread.current.thread_variable_get(PRECISION_KEY)
159
+ Function::Math.precision = precision
160
+ block.call
161
+ ensure
162
+ Function::Math.precision = previous if block
163
+ end
164
+
165
+ # Checks whether a value is approximately zero.
166
+ # @param value [Numeric] value to check
167
+ # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
168
+ # @return [Boolean]
50
169
  def zero?(value, precision: nil) = eq?(value, 0.0, precision:)
51
170
  end
52
171
 
@@ -4,49 +4,96 @@ 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 and is executable.
10
+ # @param program [Object] program name, absolute path, or relative slash-containing path
11
+ # @return [Boolean] true when an executable regular file is found
12
+ # @note PATH is evaluated on every call; empty PATH segments mean the current directory.
8
13
  def executable?(program)
9
- executable_cache.fetch(program) do
10
- executable_cache[program] = ENV["PATH"].split(::File::PATH_SEPARATOR).any? do |dir|
11
- ::File.executable?(::File.join(dir, program))
12
- end
14
+ program = program.to_s
15
+ return false if program.empty?
16
+ return executable_file?(program) if slash_path?(program)
17
+
18
+ ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR, -1).any? do |dir|
19
+ executable_file?(::File.join(dir.empty? ? "." : dir, program))
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
 
94
+ @coathooks = 0
42
95
  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
48
-
49
- block(stdout, stderr, wait_thread)
96
+ capture(stdin, stdout, stderr, wait_thread, &input)
50
97
  end
51
98
 
52
99
  Result.new(args, outs, errs, status.exitstatus)
@@ -54,17 +101,47 @@ module Sevgi
54
101
 
55
102
  private
56
103
 
57
- def block(stdout, stderr, wait_thread)
104
+ # rubocop:disable Lint/RescueException
105
+ def capture(stdin, stdout, stderr, wait_thread, &input)
58
106
  # Handle `^C`
59
- trap("INT") { handle_sigint(wait_thread.pid) }
107
+ previous = trap("INT") { handle_sigint(wait_thread.pid) }
108
+ readers = start_readers(stdout, stderr)
109
+
110
+ read_process(stdin, wait_thread, readers, &input)
111
+ rescue Exception
112
+ cleanup_failed_capture(stdin, wait_thread, readers)
113
+ raise
114
+ ensure
115
+ close_input(stdin)
116
+ trap("INT", previous) if previous
117
+ end
118
+ # rubocop:enable Lint/RescueException
119
+
120
+ def start_readers(stdout, stderr)
121
+ [
122
+ Thread.new { stdout.readlines.map(&:chomp) },
123
+ Thread.new { stderr.readlines.map(&:chomp) }
124
+ ]
125
+ end
60
126
 
61
- outs = stdout.readlines.map(&:chomp)
62
- errs = stderr.readlines.map(&:chomp)
127
+ def read_process(stdin, wait_thread, readers, &input)
128
+ write_input(stdin, &input)
129
+ close_input(stdin)
63
130
 
64
- [outs, errs, wait_thread.value]
131
+ status = wait_thread.value
132
+
133
+ [readers[0].value, readers[1].value, status]
134
+ end
135
+
136
+ def cleanup_failed_capture(stdin, wait_thread, readers)
137
+ close_input(stdin)
138
+ stop_process(wait_thread)
139
+ join_readers(readers)
65
140
  end
66
141
 
67
142
  def handle_sigint(pid)
143
+ @coathooks += 1
144
+
68
145
  message, signal = if @coathooks > 1
69
146
  ["SIGINT received again. Force quitting...", "KILL"]
70
147
  else
@@ -74,14 +151,66 @@ module Sevgi
74
151
  warn
75
152
  warn(message)
76
153
  ::Process.kill(signal, pid)
77
- @coathooks += 1
78
154
  rescue Errno::ESRCH
79
155
  warn("No process to kill.")
80
156
  end
157
+
158
+ def write_input(stdin)
159
+ return unless block_given?
160
+
161
+ content = yield
162
+ stdin.write(content) if content
163
+ end
164
+
165
+ def close_input(stdin)
166
+ stdin.close unless stdin.closed?
167
+ rescue IOError
168
+ nil
169
+ end
170
+
171
+ def stop_process(wait_thread)
172
+ return unless wait_thread&.alive?
173
+
174
+ kill_process("TERM", wait_thread.pid)
175
+ return if wait_thread.join(1)
176
+
177
+ kill_process("KILL", wait_thread.pid)
178
+ wait_thread.join
179
+ end
180
+
181
+ def kill_process(signal, pid)
182
+ ::Process.kill(signal, pid)
183
+ rescue Errno::ESRCH
184
+ nil
185
+ end
186
+
187
+ def join_readers(readers)
188
+ Array(readers).each(&:join)
189
+ end
81
190
  end
82
191
 
192
+ # @overload sh(*args, &block)
193
+ # Runs a command and captures stdout, stderr, and exit status.
194
+ # @param args [Array<String>] command and arguments
195
+ # @yield optional stdin producer, evaluated once after output readers start
196
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
197
+ # @return [Sevgi::Function::Shell::Result]
198
+ # @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
199
+ # @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
200
+ # @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
201
+ # child process and the second SIGINT sends KILL; the previous SIGINT handler is restored before return.
83
202
  def sh(...) = Runner.new.(...)
84
203
 
204
+ # Runs a command, requiring both executable lookup and successful exit status.
205
+ # @param args [Array<String>] command and arguments
206
+ # @yield optional stdin producer, evaluated once after output readers start
207
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
208
+ # @return [Sevgi::Function::Shell::Result]
209
+ # @raise [Sevgi::Error] when the executable is missing or the command fails
210
+ # @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
211
+ # @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
212
+ # @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
213
+ # child process and the second SIGINT sends KILL; the previous SIGINT handler is restored before return.
85
214
  def sh!(*args, &block)
86
215
  executable!(*args) unless args.empty?
87
216
 
@@ -90,14 +219,20 @@ module Sevgi
90
219
  warn(result.err)
91
220
  warn("")
92
221
 
93
- raise "Command failed: #{result.cmd}"
222
+ Error.("Command failed: #{result.cmd}")
94
223
  end
95
224
  end
96
225
  end
97
226
 
98
227
  private
99
228
 
100
- def executable_cache = @executable_cache ||= {}
229
+ def executable_file?(path)
230
+ ::File.file?(path) && ::File.executable?(path)
231
+ end
232
+
233
+ def slash_path?(program)
234
+ program.include?(::File::SEPARATOR) || (::File::ALT_SEPARATOR && program.include?(::File::ALT_SEPARATOR))
235
+ end
101
236
  end
102
237
 
103
238
  extend Shell
@@ -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,23 +19,23 @@ 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
20
- UNCOUNTABLES = Hash[
21
- *%w[
22
- sheep
23
- fish
24
- sheep
25
- series
26
- species
27
- money
28
- rice
29
- information
30
- equipment
31
- ].map { [it, true] }.flatten
24
+ # Words that should not be pluralized.
25
+ UNCOUNTABLES = %w[
26
+ equipment
27
+ fish
28
+ information
29
+ money
30
+ rice
31
+ series
32
+ sheep
33
+ species
32
34
  ]
35
+ .to_h { [it, true] }
33
36
  .freeze
34
37
 
38
+ # Singular-to-plural forms that do not follow suffix rules.
35
39
  IRREGULARS = Hash[
36
40
  *%w[
37
41
  child
@@ -54,8 +58,10 @@ module Sevgi
54
58
  ]
55
59
  .freeze
56
60
 
61
+ # Plural forms already accepted as plural.
57
62
  PLURALS = IRREGULARS.invert.freeze
58
63
 
64
+ # Ordered suffix replacement rules.
59
65
  RULES = [
60
66
  [/(quiz)$/i, "\\1zes"],
61
67
  [/^(oxen)$/i, "\\1"],
@@ -80,15 +86,18 @@ module Sevgi
80
86
  [/$/, "s"]
81
87
  ].freeze
82
88
 
83
- # Pluralize.('post') # => "posts"
84
- # Pluralize.('octopus') # => "octopi"
85
- # Pluralize.('sheep') # => "sheep"
86
- # Pluralize.('words') # => "words"
87
- # Pluralize.('CamelOctopus') # => "CamelOctopi"
89
+ # Pluralizes an English word using a small built-in rule set.
90
+ # @param word [Object] word to pluralize
91
+ # @return [String]
92
+ # @example
93
+ # F.pluralize("post") # => "posts"
94
+ # F.pluralize("octopus") # => "octopi"
95
+ # F.pluralize("sheep") # => "sheep"
96
+ # F.pluralize("CamelOctopus") # => "CamelOctopi"
88
97
  def pluralize(word)
89
98
  result = word.to_s.dup
90
99
 
91
- return result if word.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
100
+ return result if result.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
92
101
  return IRREGULARS[result] if IRREGULARS.key?(result)
93
102
 
94
103
  RULES.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
@@ -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.94.0"
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.94.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,6 +15,8 @@ executables: []
15
15
  extensions: []
16
16
  extra_rdoc_files: []
17
17
  files:
18
+ - CHANGELOG.md
19
+ - LICENSE
18
20
  - README.md
19
21
  - lib/sevgi/core.rb
20
22
  - lib/sevgi/function.rb
@@ -41,7 +43,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
41
43
  requirements:
42
44
  - - ">="
43
45
  - !ruby/object:Gem::Version
44
- version: 3.4.0.pre.preview1
46
+ version: 3.4.0
45
47
  required_rubygems_version: !ruby/object:Gem::Requirement
46
48
  requirements:
47
49
  - - ">="