sevgi-function 0.93.1 → 0.95.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 +152 -0
- data/LICENSE +674 -0
- data/README.md +33 -2
- data/lib/sevgi/core.rb +4 -18
- data/lib/sevgi/function/locate.rb +64 -35
- data/lib/sevgi/function/math.rb +29 -4
- data/lib/sevgi/function/shell.rb +197 -29
- data/lib/sevgi/function/string.rb +11 -13
- data/lib/sevgi/function/version.rb +1 -1
- metadata +5 -3
data/README.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Sevgi Function
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared helper functions used by Sevgi components.
|
|
4
4
|
|
|
5
|
-
|
|
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.0 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
|
@@ -45,27 +45,10 @@ module Sevgi
|
|
|
45
45
|
# Error raised for invalid public API usage.
|
|
46
46
|
ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
|
|
47
47
|
|
|
48
|
-
# Shared immutable empty array.
|
|
49
|
-
EMPTY_ARRAY = [].freeze
|
|
50
|
-
|
|
51
|
-
# Shared immutable empty hash.
|
|
52
|
-
EMPTY_HASH = {}.freeze
|
|
53
|
-
|
|
54
|
-
# Shared immutable empty options hash.
|
|
55
|
-
EMPTY_OPTS = {}.freeze
|
|
56
|
-
|
|
57
|
-
# Shared empty string.
|
|
58
|
-
EMPTY_STRING = ""
|
|
59
|
-
|
|
60
|
-
# Identity callable.
|
|
61
|
-
IDENTITY = -> (x) { x }.freeze
|
|
62
|
-
|
|
63
48
|
# Sentinel object used to distinguish an omitted value from nil.
|
|
64
49
|
Undefined = Object
|
|
65
50
|
.new
|
|
66
51
|
.tap do |undefined|
|
|
67
|
-
const_set(:Self, -> { Undefined })
|
|
68
|
-
|
|
69
52
|
# Returns the sentinel name.
|
|
70
53
|
# @return [String]
|
|
71
54
|
def undefined.to_s = "Undefined"
|
|
@@ -105,7 +88,10 @@ module Sevgi
|
|
|
105
88
|
# Returns the first argument that is not {Sevgi::Undefined}.
|
|
106
89
|
# @param args [Array<Object>] candidate values
|
|
107
90
|
# @return [Object, nil] first defined value, including nil, or nil when none exists
|
|
108
|
-
|
|
91
|
+
# @example Resolve an optional value
|
|
92
|
+
# Sevgi::Undefined.coalesce(Sevgi::Undefined, nil) # => nil
|
|
93
|
+
# @see #default
|
|
94
|
+
def undefined.coalesce(*args) = args.find { |x| !equal?(x) }
|
|
109
95
|
end
|
|
110
96
|
.freeze
|
|
111
97
|
end
|
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
# Locates one of several candidate files by walking upward from a start directory.
|
|
6
16
|
class Locate
|
|
7
17
|
# @overload call(paths, start = Dir.pwd, exclude: nil, &block)
|
|
@@ -12,64 +22,83 @@ module Sevgi
|
|
|
12
22
|
# @yield optional matcher used instead of file existence checks
|
|
13
23
|
# @yieldparam path [String] candidate path
|
|
14
24
|
# @yieldreturn [Boolean]
|
|
15
|
-
# @return [Sevgi::Function::
|
|
25
|
+
# @return [Sevgi::Function::Location, nil] found location, or nil
|
|
16
26
|
def self.call(*, **, &block) = new(*, **).call(&block)
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
# Returns candidate paths.
|
|
29
|
+
# @return [Array<String>]
|
|
30
|
+
attr_reader :paths
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
# Returns the absolute start directory.
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :start
|
|
21
35
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
# @return [String] start directory
|
|
26
|
-
# @!attribute [r] exclude
|
|
27
|
-
# @return [Array<String>, nil] expanded paths ignored during lookup
|
|
28
|
-
attr_reader :paths, :start, :exclude
|
|
36
|
+
# Returns expanded paths ignored during lookup.
|
|
37
|
+
# @return [Array<String>, nil]
|
|
38
|
+
attr_reader :exclude
|
|
29
39
|
|
|
30
40
|
# Builds an upward file locator.
|
|
31
41
|
# @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
|
|
42
|
+
# @param start [String] directory where lookup starts, expanded without changing process cwd
|
|
43
|
+
# @param exclude [Array<String>, String, nil] paths ignored during lookup after absolute expansion
|
|
34
44
|
# @return [void]
|
|
35
45
|
def initialize(paths, start = ::Dir.pwd, exclude: nil)
|
|
36
46
|
@paths = Array(paths)
|
|
37
|
-
@start = start
|
|
47
|
+
@start = ::File.expand_path(start)
|
|
38
48
|
@exclude = [*exclude].map { ::File.expand_path(it) } unless exclude.nil?
|
|
39
49
|
end
|
|
40
50
|
|
|
41
51
|
# Runs the upward lookup.
|
|
42
52
|
# @yield optional matcher used instead of file existence checks
|
|
43
|
-
# @yieldparam path [String] candidate path
|
|
53
|
+
# @yieldparam path [String] absolute candidate path
|
|
44
54
|
# @yieldreturn [Boolean]
|
|
45
|
-
# @
|
|
46
|
-
# @
|
|
55
|
+
# @note Absolute exclusions are applied before the default or custom matcher.
|
|
56
|
+
# @return [Sevgi::Function::Location, nil] found location, or nil
|
|
57
|
+
# @raise [Errno::ENOENT] when the start directory does not exist
|
|
58
|
+
# @raise [Errno::ENOTDIR] when the start path is not a directory
|
|
47
59
|
def call(&block)
|
|
48
|
-
|
|
49
|
-
::Dir.chdir(start)
|
|
60
|
+
validate_start!
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
::Dir.chdir("..")
|
|
54
|
-
::Dir.pwd == here ? return : here = ::Dir.pwd
|
|
55
|
-
end
|
|
62
|
+
each_parent(start) do |here|
|
|
63
|
+
next unless (found = match(here, &block))
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
slug, file = found
|
|
66
|
+
|
|
67
|
+
return Location[file, slug, here]
|
|
68
|
+
end
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
private
|
|
63
72
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
def each_parent(here)
|
|
74
|
+
loop do
|
|
75
|
+
yield here
|
|
76
|
+
|
|
77
|
+
parent = ::File.dirname(here)
|
|
78
|
+
break if parent == here
|
|
79
|
+
|
|
80
|
+
here = parent
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def excluded?(candidate)
|
|
85
|
+
exclude&.include?(candidate)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def match(here, &block)
|
|
89
|
+
paths.each do |path|
|
|
90
|
+
candidate = ::File.expand_path(path, here)
|
|
91
|
+
next if excluded?(candidate)
|
|
92
|
+
|
|
93
|
+
return [path, candidate] if (block || proc { ::File.exist?(it) }).call(candidate)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
71
98
|
|
|
72
|
-
|
|
99
|
+
def validate_start!
|
|
100
|
+
raise ::Errno::ENOENT, start unless ::File.exist?(start)
|
|
101
|
+
raise ::Errno::ENOTDIR, start unless ::File.directory?(start)
|
|
73
102
|
end
|
|
74
103
|
end
|
|
75
104
|
|
|
@@ -78,7 +107,7 @@ module Sevgi
|
|
|
78
107
|
# @param start [String] directory where lookup starts
|
|
79
108
|
# @param exclude [Array<String>, String, nil] paths ignored during lookup
|
|
80
109
|
# @param extension [String] default extension added before lookup
|
|
81
|
-
# @return [Sevgi::Function::
|
|
110
|
+
# @return [Sevgi::Function::Location] found location
|
|
82
111
|
# @raise [Sevgi::Error] when no matching file exists
|
|
83
112
|
def self.locate(filename, start, exclude: nil, extension: EXTENSION)
|
|
84
113
|
Locate.(F.qualify(filename, extension), start, exclude:).tap do |path|
|
data/lib/sevgi/function/math.rb
CHANGED
|
@@ -74,11 +74,17 @@ module Sevgi
|
|
|
74
74
|
def cot(degrees) = 1.0 / ::Math.tan(to_radians(degrees))
|
|
75
75
|
|
|
76
76
|
# Counts complete divisions in a length.
|
|
77
|
-
# @param length [Numeric] total length
|
|
78
|
-
# @param division [Numeric] division size
|
|
77
|
+
# @param length [Numeric] finite total length
|
|
78
|
+
# @param division [Numeric] finite, non-zero division size
|
|
79
79
|
# @return [Integer]
|
|
80
|
-
# @raise [
|
|
81
|
-
def count(length, division)
|
|
80
|
+
# @raise [Sevgi::ArgumentError] when an operand is not a finite Numeric or division is zero
|
|
81
|
+
def count(length, division)
|
|
82
|
+
length = finite_real(:length, length)
|
|
83
|
+
divisor = finite_real(:division, division)
|
|
84
|
+
ArgumentError.("Division must not be zero") if divisor.zero?
|
|
85
|
+
|
|
86
|
+
(length / divisor).to_i
|
|
87
|
+
end
|
|
82
88
|
|
|
83
89
|
# Compares two numeric values after approximate rounding.
|
|
84
90
|
# @param left [Numeric] left operand
|
|
@@ -162,6 +168,25 @@ module Sevgi
|
|
|
162
168
|
# @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
|
|
163
169
|
# @return [Boolean]
|
|
164
170
|
def zero?(value, precision: nil) = eq?(value, 0.0, precision:)
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def finite_real(field, value)
|
|
175
|
+
unless value.is_a?(::Numeric)
|
|
176
|
+
ArgumentError.("#{field} must be a finite Numeric: #{value.inspect}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
number = begin
|
|
180
|
+
value.to_f
|
|
181
|
+
rescue ::StandardError => e
|
|
182
|
+
ArgumentError.("#{field} must be a finite Numeric: #{value.inspect} (#{e.message})")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
ArgumentError.("#{field} must be finite: #{value.inspect}") unless number.is_a?(::Float) && number.finite?
|
|
186
|
+
|
|
187
|
+
number
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
end
|
|
166
191
|
|
|
167
192
|
extend Math
|
data/lib/sevgi/function/shell.rb
CHANGED
|
@@ -6,17 +6,17 @@ module Sevgi
|
|
|
6
6
|
module Function
|
|
7
7
|
# Shell execution helpers and executable lookup utilities.
|
|
8
8
|
module Shell
|
|
9
|
-
# Checks whether a program exists
|
|
10
|
-
# @param program [Object] program name
|
|
11
|
-
# @return [Boolean] true when an executable
|
|
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.
|
|
12
13
|
def executable?(program)
|
|
13
14
|
program = program.to_s
|
|
14
15
|
return false if program.empty?
|
|
16
|
+
return executable_file?(program) if slash_path?(program)
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
::File.executable?(::File.join(dir, program))
|
|
19
|
-
end
|
|
18
|
+
ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR, -1).any? do |dir|
|
|
19
|
+
executable_file?(::File.join(dir.empty? ? "." : dir, program))
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -24,8 +24,9 @@ module Sevgi
|
|
|
24
24
|
# @param args [Array<Object>] command arguments
|
|
25
25
|
# @return [nil]
|
|
26
26
|
# @raise [Sevgi::Error] when the program cannot be found in PATH
|
|
27
|
+
# @note The first argument is checked as one exact argv entry; it is never shell-split.
|
|
27
28
|
def executable!(*args)
|
|
28
|
-
program = args.first.to_s
|
|
29
|
+
program = args.first.to_s
|
|
29
30
|
Error.("Missing executable: #{program}") unless executable?(program)
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -73,6 +74,101 @@ module Sevgi
|
|
|
73
74
|
def self.dummy = new([], [], [], 0)
|
|
74
75
|
end
|
|
75
76
|
|
|
77
|
+
# Shared process-global SIGINT state for overlapping shell runners.
|
|
78
|
+
# @api private
|
|
79
|
+
module Signals
|
|
80
|
+
Entry = Data.define(:runner, :pid)
|
|
81
|
+
WAKE = "."
|
|
82
|
+
|
|
83
|
+
private_constant :WAKE
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
def register(runner, pid)
|
|
87
|
+
mutex.synchronize do
|
|
88
|
+
install unless entries.any?
|
|
89
|
+
entries[runner] = Entry.new(runner, pid)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unregister(runner)
|
|
94
|
+
worker = mutex.synchronize do
|
|
95
|
+
next unless entries.delete(runner)
|
|
96
|
+
|
|
97
|
+
restore unless entries.any?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
worker.join if worker && worker != Thread.current
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def dispatch
|
|
106
|
+
active = mutex.synchronize { entries.values.dup }
|
|
107
|
+
active.each { dispatch_entry(it) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def dispatch_entry(entry)
|
|
111
|
+
entry.runner.send(:handle_sigint, entry.pid)
|
|
112
|
+
rescue ::StandardError => e
|
|
113
|
+
warn("SIGINT dispatch failed: #{e.message}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def install
|
|
117
|
+
reader, writer = ::IO.pipe
|
|
118
|
+
worker = Thread.new { listen(reader) }
|
|
119
|
+
previous = Signal.trap("INT") { notify(writer) }
|
|
120
|
+
|
|
121
|
+
@previous = previous
|
|
122
|
+
@worker = worker
|
|
123
|
+
@writer = writer
|
|
124
|
+
rescue ::StandardError
|
|
125
|
+
Signal.trap("INT", previous) if previous
|
|
126
|
+
close_io(writer)
|
|
127
|
+
worker&.join
|
|
128
|
+
close_io(reader)
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def listen(reader)
|
|
133
|
+
dispatch while reader.read(1)
|
|
134
|
+
rescue ::IOError, ::SystemCallError
|
|
135
|
+
nil
|
|
136
|
+
ensure
|
|
137
|
+
close_io(reader)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def mutex = @mutex ||= Mutex.new
|
|
141
|
+
|
|
142
|
+
def entries = @entries ||= {}
|
|
143
|
+
|
|
144
|
+
def notify(writer)
|
|
145
|
+
writer.write_nonblock(WAKE, exception: false)
|
|
146
|
+
rescue ::IOError, ::SystemCallError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def restore
|
|
151
|
+
Signal.trap("INT", @previous)
|
|
152
|
+
@previous = nil
|
|
153
|
+
|
|
154
|
+
writer = @writer
|
|
155
|
+
worker = @worker
|
|
156
|
+
@worker = nil
|
|
157
|
+
@writer = nil
|
|
158
|
+
close_io(writer)
|
|
159
|
+
worker
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def close_io(io)
|
|
163
|
+
io&.close unless io&.closed?
|
|
164
|
+
rescue ::IOError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private_constant :Signals
|
|
171
|
+
|
|
76
172
|
# Runs shell commands and captures stdout, stderr, and exit status.
|
|
77
173
|
# @api private
|
|
78
174
|
class Runner
|
|
@@ -91,12 +187,9 @@ module Sevgi
|
|
|
91
187
|
def call(*args, &input)
|
|
92
188
|
return Result.dummy if args.empty?
|
|
93
189
|
|
|
190
|
+
@coathooks = 0
|
|
94
191
|
outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
|
|
95
|
-
|
|
96
|
-
stdin.write(content) if content
|
|
97
|
-
stdin.close
|
|
98
|
-
|
|
99
|
-
capture(stdout, stderr, wait_thread)
|
|
192
|
+
capture(stdin, stdout, stderr, wait_thread, &input)
|
|
100
193
|
end
|
|
101
194
|
|
|
102
195
|
Result.new(args, outs, errs, status.exitstatus)
|
|
@@ -104,19 +197,48 @@ module Sevgi
|
|
|
104
197
|
|
|
105
198
|
private
|
|
106
199
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
200
|
+
# rubocop:disable Lint/RescueException
|
|
201
|
+
def capture(stdin, stdout, stderr, wait_thread, &input)
|
|
202
|
+
registered = false
|
|
203
|
+
Signals.register(self, wait_thread.pid)
|
|
204
|
+
registered = true
|
|
205
|
+
readers = start_readers(stdout, stderr)
|
|
110
206
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
207
|
+
read_process(stdin, wait_thread, readers, &input)
|
|
208
|
+
rescue Exception
|
|
209
|
+
cleanup_failed_capture(stdin, wait_thread, readers)
|
|
210
|
+
raise
|
|
115
211
|
ensure
|
|
116
|
-
|
|
212
|
+
close_input(stdin)
|
|
213
|
+
Signals.unregister(self) if registered
|
|
214
|
+
end
|
|
215
|
+
# rubocop:enable Lint/RescueException
|
|
216
|
+
|
|
217
|
+
def start_readers(stdout, stderr)
|
|
218
|
+
[
|
|
219
|
+
Thread.new { stdout.readlines.map(&:chomp) },
|
|
220
|
+
Thread.new { stderr.readlines.map(&:chomp) }
|
|
221
|
+
]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def read_process(stdin, wait_thread, readers, &input)
|
|
225
|
+
write_input(stdin, &input)
|
|
226
|
+
close_input(stdin)
|
|
227
|
+
|
|
228
|
+
status = wait_thread.value
|
|
229
|
+
|
|
230
|
+
[readers[0].value, readers[1].value, status]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def cleanup_failed_capture(stdin, wait_thread, readers)
|
|
234
|
+
close_input(stdin)
|
|
235
|
+
stop_process(wait_thread)
|
|
236
|
+
join_readers(readers)
|
|
117
237
|
end
|
|
118
238
|
|
|
119
239
|
def handle_sigint(pid)
|
|
240
|
+
@coathooks += 1
|
|
241
|
+
|
|
120
242
|
message, signal = if @coathooks > 1
|
|
121
243
|
["SIGINT received again. Force quitting...", "KILL"]
|
|
122
244
|
else
|
|
@@ -126,28 +248,68 @@ module Sevgi
|
|
|
126
248
|
warn
|
|
127
249
|
warn(message)
|
|
128
250
|
::Process.kill(signal, pid)
|
|
129
|
-
@coathooks += 1
|
|
130
251
|
rescue Errno::ESRCH
|
|
131
252
|
warn("No process to kill.")
|
|
132
253
|
end
|
|
254
|
+
|
|
255
|
+
def write_input(stdin)
|
|
256
|
+
return unless block_given?
|
|
257
|
+
|
|
258
|
+
content = yield
|
|
259
|
+
stdin.write(content) if content
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def close_input(stdin)
|
|
263
|
+
stdin.close unless stdin.closed?
|
|
264
|
+
rescue IOError
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def stop_process(wait_thread)
|
|
269
|
+
return unless wait_thread&.alive?
|
|
270
|
+
|
|
271
|
+
kill_process("TERM", wait_thread.pid)
|
|
272
|
+
return if wait_thread.join(1)
|
|
273
|
+
|
|
274
|
+
kill_process("KILL", wait_thread.pid)
|
|
275
|
+
wait_thread.join
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def kill_process(signal, pid)
|
|
279
|
+
::Process.kill(signal, pid)
|
|
280
|
+
rescue Errno::ESRCH
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def join_readers(readers)
|
|
285
|
+
Array(readers).each(&:join)
|
|
286
|
+
end
|
|
133
287
|
end
|
|
134
288
|
|
|
135
289
|
# @overload sh(*args, &block)
|
|
136
290
|
# Runs a command and captures stdout, stderr, and exit status.
|
|
137
291
|
# @param args [Array<String>] command and arguments
|
|
138
|
-
# @yield optional
|
|
139
|
-
# @yieldreturn [String, nil]
|
|
292
|
+
# @yield optional stdin producer, evaluated once after output readers start
|
|
293
|
+
# @yieldreturn [String, nil] content to write to stdin; nil writes nothing
|
|
140
294
|
# @return [Sevgi::Function::Shell::Result]
|
|
141
|
-
# @raise [
|
|
295
|
+
# @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
|
|
296
|
+
# @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
|
|
297
|
+
# @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
|
|
298
|
+
# child process and the second SIGINT as KILL to each active child outside trap context, then restores the
|
|
299
|
+
# previous handler.
|
|
142
300
|
def sh(...) = Runner.new.(...)
|
|
143
301
|
|
|
144
302
|
# Runs a command, requiring both executable lookup and successful exit status.
|
|
145
303
|
# @param args [Array<String>] command and arguments
|
|
146
|
-
# @yield optional
|
|
147
|
-
# @yieldreturn [String, nil]
|
|
304
|
+
# @yield optional stdin producer, evaluated once after output readers start
|
|
305
|
+
# @yieldreturn [String, nil] content to write to stdin; nil writes nothing
|
|
148
306
|
# @return [Sevgi::Function::Shell::Result]
|
|
149
307
|
# @raise [Sevgi::Error] when the executable is missing or the command fails
|
|
150
|
-
# @raise [
|
|
308
|
+
# @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
|
|
309
|
+
# @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
|
|
310
|
+
# @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
|
|
311
|
+
# child process and the second SIGINT as KILL to each active child outside trap context, then restores the previous
|
|
312
|
+
# handler.
|
|
151
313
|
def sh!(*args, &block)
|
|
152
314
|
executable!(*args) unless args.empty?
|
|
153
315
|
|
|
@@ -163,7 +325,13 @@ module Sevgi
|
|
|
163
325
|
|
|
164
326
|
private
|
|
165
327
|
|
|
166
|
-
def
|
|
328
|
+
def executable_file?(path)
|
|
329
|
+
::File.file?(path) && ::File.executable?(path)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def slash_path?(program)
|
|
333
|
+
program.include?(::File::SEPARATOR) || (::File::ALT_SEPARATOR && program.include?(::File::ALT_SEPARATOR))
|
|
334
|
+
end
|
|
167
335
|
end
|
|
168
336
|
|
|
169
337
|
extend Shell
|
|
@@ -22,19 +22,17 @@ module Sevgi
|
|
|
22
22
|
# Lightweight English pluralization helper.
|
|
23
23
|
module Pluralize
|
|
24
24
|
# Words that should not be pluralized.
|
|
25
|
-
UNCOUNTABLES =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
information
|
|
35
|
-
equipment
|
|
36
|
-
].map { [it, true] }.flatten
|
|
25
|
+
UNCOUNTABLES = %w[
|
|
26
|
+
equipment
|
|
27
|
+
fish
|
|
28
|
+
information
|
|
29
|
+
money
|
|
30
|
+
rice
|
|
31
|
+
series
|
|
32
|
+
sheep
|
|
33
|
+
species
|
|
37
34
|
]
|
|
35
|
+
.to_h { [it, true] }
|
|
38
36
|
.freeze
|
|
39
37
|
|
|
40
38
|
# Singular-to-plural forms that do not follow suffix rules.
|
|
@@ -99,7 +97,7 @@ module Sevgi
|
|
|
99
97
|
def pluralize(word)
|
|
100
98
|
result = word.to_s.dup
|
|
101
99
|
|
|
102
|
-
return result if
|
|
100
|
+
return result if result.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
|
|
103
101
|
return IRREGULARS[result] if IRREGULARS.key?(result)
|
|
104
102
|
|
|
105
103
|
RULES.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
|
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.95.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,14 +43,14 @@ 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
|
- - ">="
|
|
48
50
|
- !ruby/object:Gem::Version
|
|
49
51
|
version: '0'
|
|
50
52
|
requirements: []
|
|
51
|
-
rubygems_version: 4.0.
|
|
53
|
+
rubygems_version: 4.0.11
|
|
52
54
|
specification_version: 4
|
|
53
55
|
summary: Various utility functions for the Sevgi toolkit.
|
|
54
56
|
test_files: []
|