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 +4 -4
- data/CHANGELOG.md +4 -0
- data/LICENSE +5 -0
- data/README.md +36 -0
- data/lib/sevgi/core.rb +56 -11
- data/lib/sevgi/function/color.rb +31 -0
- data/lib/sevgi/function/file.rb +58 -10
- data/lib/sevgi/function/locate.rb +82 -24
- data/lib/sevgi/function/math.rb +124 -5
- data/lib/sevgi/function/shell.rb +158 -23
- data/lib/sevgi/function/string.rb +28 -19
- data/lib/sevgi/function/ui.rb +22 -0
- data/lib/sevgi/function/version.rb +2 -1
- data/lib/sevgi/function.rb +8 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7afe91e24bcfcbc6b2e398838f8cf9cce6fdb97b9c7b7cded831a6f892c75280
|
|
4
|
+
data.tar.gz: 505afa54ddc3e02573ed7169ca6aa01f14a90bd3c57d41bafe0cb87bb7b47e46
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd9066dc9cc025ff15122265b66ace4861a62b150a10ac72addd1255bf3223337cd1ad3c6e0259423abc184a98db97cb0f5c0e0769224240e20db8ae8654ee41
|
|
7
|
+
data.tar.gz: 0d47888b1cce2c39fba38c09ead6044401908ae48d789ff596ee0cb7055c2225cab69b094803c84a8a8d1737d4d2a3268e1f769154e9770c55c01e9813f6160e
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
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
|
-
#
|
|
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)
|
|
17
|
-
ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
|
|
18
44
|
|
|
19
|
-
#
|
|
20
|
-
|
|
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
|
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,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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
22
|
-
::Dir.chdir(start)
|
|
55
|
+
validate_start!
|
|
23
56
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
57
|
+
each_parent(start) do |here|
|
|
58
|
+
next unless (found = match(here, &block))
|
|
59
|
+
|
|
60
|
+
slug, file = found
|
|
29
61
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
::Dir.chdir(origin)
|
|
62
|
+
return Location[file, slug, here]
|
|
63
|
+
end
|
|
33
64
|
end
|
|
34
65
|
|
|
35
66
|
private
|
|
36
67
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
Error.("Cannot load a file matching: #{filename}") unless path
|
|
52
110
|
end
|
|
53
111
|
end
|
|
54
112
|
end
|
data/lib/sevgi/function/math.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
28
|
-
|
|
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
|
|
data/lib/sevgi/function/shell.rb
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
|
94
|
+
@coathooks = 0
|
|
42
95
|
outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
127
|
+
def read_process(stdin, wait_thread, readers, &input)
|
|
128
|
+
write_input(stdin, &input)
|
|
129
|
+
close_input(stdin)
|
|
63
130
|
|
|
64
|
-
|
|
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
|
-
|
|
222
|
+
Error.("Command failed: #{result.cmd}")
|
|
94
223
|
end
|
|
95
224
|
end
|
|
96
225
|
end
|
|
97
226
|
|
|
98
227
|
private
|
|
99
228
|
|
|
100
|
-
def
|
|
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
|
-
#
|
|
22
|
+
# Lightweight English pluralization helper.
|
|
19
23
|
module Pluralize
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
|
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) }
|
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
|
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.
|
|
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
|
|
46
|
+
version: 3.4.0
|
|
45
47
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
48
|
requirements:
|
|
47
49
|
- - ">="
|