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 +4 -4
- data/README.md +5 -0
- data/lib/sevgi/core.rb +65 -5
- data/lib/sevgi/function/color.rb +31 -0
- data/lib/sevgi/function/file.rb +58 -10
- data/lib/sevgi/function/locate.rb +35 -1
- data/lib/sevgi/function/math.rb +118 -4
- data/lib/sevgi/function/shell.rb +83 -17
- data/lib/sevgi/function/string.rb +17 -6
- data/lib/sevgi/function/ui.rb +22 -0
- data/lib/sevgi/function/version.rb +2 -1
- data/lib/sevgi/function.rb +8 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 060b3cdf233db4debfb3429c2d8663d94840ae00de70980124089f2d5d04c675
|
|
4
|
+
data.tar.gz: 2f80215534863a248171055a829ddcfa36ccf73013fbd430064b04fade3eeb5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4de61331c47e0007652e6c7548adf6de50e8d4ded0e1bc95c05dc9d7a0fc9929ee4ffdd3af3b3cd73eddc777495691c4952186e3f0f34390edcee062f0e907b
|
|
7
|
+
data.tar.gz: a36e58998da54d4c687e9266e1c58290af919ab3e4229627ab2d4c5a9b0ec4bd7a04a053ef8e8779ea3ef51268c1add54c75328475904562267d7073e683fa3f
|
data/README.md
CHANGED
data/lib/sevgi/core.rb
CHANGED
|
@@ -1,50 +1,110 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/sevgi/function/color.rb
CHANGED
|
@@ -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
|
|
data/lib/sevgi/function/file.rb
CHANGED
|
@@ -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
|
-
|
|
46
|
+
ArgumentError.("No matching file(s) found: #{file}") unless found
|
|
29
47
|
end
|
|
30
48
|
end
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
70
|
+
ArgumentError.("No matching file(s) found: #{missings.join(", ")}") unless missings.empty?
|
|
43
71
|
|
|
44
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
85
|
+
Error.("Cannot load a file matching: #{filename}") unless path
|
|
52
86
|
end
|
|
53
87
|
end
|
|
54
88
|
end
|
data/lib/sevgi/function/math.rb
CHANGED
|
@@ -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
|
-
|
|
7
|
+
# Default decimal precision used by approximate comparisons.
|
|
8
|
+
PRECISION = 6
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/sevgi/function/shell.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
stdin.close
|
|
47
|
-
end
|
|
95
|
+
content = input.call if input
|
|
96
|
+
stdin.write(content) if content
|
|
97
|
+
stdin.close
|
|
48
98
|
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
|
data/lib/sevgi/function/ui.rb
CHANGED
|
@@ -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
|
|
data/lib/sevgi/function.rb
CHANGED
|
@@ -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
|