sevgi-function 0.94.0 → 0.98.2
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 +275 -2
- data/LICENSE +672 -3
- data/README.md +9 -7
- data/lib/sevgi/core.rb +4 -3
- data/lib/sevgi/function/color.rb +2 -1
- data/lib/sevgi/function/file.rb +8 -9
- data/lib/sevgi/function/locate.rb +40 -17
- data/lib/sevgi/function/math.rb +104 -22
- data/lib/sevgi/function/shell.rb +169 -22
- data/lib/sevgi/function/string.rb +18 -8
- data/lib/sevgi/function/ui.rb +2 -1
- data/lib/sevgi/function/version.rb +1 -1
- data/lib/sevgi/function.rb +14 -4
- metadata +4 -4
data/lib/sevgi/function/shell.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
module Sevgi
|
|
6
7
|
module Function
|
|
7
|
-
#
|
|
8
|
+
# Command methods promoted to {Sevgi::F}. This module owns the public immutable {Shell::Result} value but is not
|
|
9
|
+
# otherwise a consumer mixin contract.
|
|
8
10
|
module Shell
|
|
9
11
|
# Checks whether a program exists and is executable.
|
|
10
12
|
# @param program [Object] program name, absolute path, or relative slash-containing path
|
|
@@ -24,29 +26,68 @@ module Sevgi
|
|
|
24
26
|
# @param args [Array<Object>] command arguments
|
|
25
27
|
# @return [nil]
|
|
26
28
|
# @raise [Sevgi::Error] when the program cannot be found in PATH
|
|
29
|
+
# @note The first argument is checked as one exact argv entry; it is never shell-split.
|
|
27
30
|
def executable!(*args)
|
|
28
|
-
program = args.first.to_s
|
|
31
|
+
program = args.first.to_s
|
|
29
32
|
Error.("Missing executable: #{program}") unless executable?(program)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
#
|
|
33
|
-
|
|
35
|
+
# Immutable result returned by shell commands.
|
|
36
|
+
#
|
|
37
|
+
# @example Inspect a command result
|
|
38
|
+
# require "rbconfig"
|
|
39
|
+
# result = Sevgi::F.sh(RbConfig.ruby, "-e", "puts 42")
|
|
40
|
+
# result.ok? # => true
|
|
41
|
+
# result.outline # => "42"
|
|
42
|
+
Result = Data.define(:args, :outs, :errs, :exit_code, :signal) do
|
|
34
43
|
# @!attribute [r] args
|
|
35
|
-
# @return [Array<String>] command arguments
|
|
44
|
+
# @return [Array<String>] frozen command arguments
|
|
36
45
|
# @!attribute [r] outs
|
|
37
|
-
# @return [Array<String>] captured stdout lines
|
|
46
|
+
# @return [Array<String>] frozen captured stdout lines
|
|
38
47
|
# @!attribute [r] errs
|
|
39
|
-
# @return [Array<String>] captured stderr lines
|
|
48
|
+
# @return [Array<String>] frozen captured stderr lines
|
|
40
49
|
# @!attribute [r] exit_code
|
|
41
50
|
# @return [Integer, nil] process exit code
|
|
51
|
+
# @!attribute [r] signal
|
|
52
|
+
# @return [Integer, nil] terminating signal number
|
|
53
|
+
|
|
54
|
+
# Creates an owned result snapshot.
|
|
55
|
+
# @param args [Array<Object>] command arguments
|
|
56
|
+
# @param outs [Array<String>] captured stdout lines
|
|
57
|
+
# @param errs [Array<String>] captured stderr lines
|
|
58
|
+
# @param exit_code [Integer, nil] process exit code
|
|
59
|
+
# @param signal [Integer, nil] terminating signal number
|
|
60
|
+
# @return [void]
|
|
61
|
+
def initialize(args:, outs:, errs:, exit_code:, signal:)
|
|
62
|
+
super(
|
|
63
|
+
args: args.map { it.to_s.dup.freeze }.freeze,
|
|
64
|
+
outs: outs.map { it.dup.freeze }.freeze,
|
|
65
|
+
errs: errs.map { it.dup.freeze }.freeze,
|
|
66
|
+
exit_code:,
|
|
67
|
+
signal:
|
|
68
|
+
)
|
|
69
|
+
end
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
private_class_method :[]
|
|
72
|
+
|
|
73
|
+
# Returns captured output with one blank line between non-empty streams.
|
|
74
|
+
# Captured lines are joined with one newline and are not otherwise trimmed.
|
|
75
|
+
# @example Combine standard output and standard error
|
|
76
|
+
# result = Sevgi::Function::Shell::Result.new(
|
|
77
|
+
# args: ["tool"], outs: ["output"], errs: ["warning"], exit_code: 0, signal: nil
|
|
78
|
+
# )
|
|
79
|
+
# result.all # => "output\n\nwarning"
|
|
80
|
+
# @return [String] combined output, or an empty string when neither stream contains lines
|
|
81
|
+
def all
|
|
82
|
+
return err if outs.empty?
|
|
83
|
+
return out if errs.empty?
|
|
84
|
+
|
|
85
|
+
"#{out}\n\n#{err}"
|
|
86
|
+
end
|
|
46
87
|
|
|
47
88
|
# Returns the command as a shell-like display string.
|
|
48
89
|
# @return [String]
|
|
49
|
-
def cmd =
|
|
90
|
+
def cmd = ::Shellwords.join(args)
|
|
50
91
|
|
|
51
92
|
# Returns captured stderr as a string.
|
|
52
93
|
# @return [String]
|
|
@@ -58,7 +99,7 @@ module Sevgi
|
|
|
58
99
|
|
|
59
100
|
# Reports whether the command exited successfully.
|
|
60
101
|
# @return [Boolean]
|
|
61
|
-
def ok? = exit_code
|
|
102
|
+
def ok? = !exit_code.nil? && exit_code.zero?
|
|
62
103
|
|
|
63
104
|
# Returns captured stdout as a string.
|
|
64
105
|
# @return [String]
|
|
@@ -68,11 +109,109 @@ module Sevgi
|
|
|
68
109
|
# @return [String, nil]
|
|
69
110
|
def outline = outs.first
|
|
70
111
|
|
|
71
|
-
#
|
|
72
|
-
# @return [
|
|
73
|
-
def
|
|
112
|
+
# Reports whether the command was terminated by a signal.
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def signaled? = !signal.nil?
|
|
74
115
|
end
|
|
75
116
|
|
|
117
|
+
# Shared process-global SIGINT state for overlapping shell runners.
|
|
118
|
+
# @api private
|
|
119
|
+
module Signals
|
|
120
|
+
Entry = Data.define(:runner, :pid) do
|
|
121
|
+
private_class_method :[]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
WAKE = "."
|
|
125
|
+
|
|
126
|
+
private_constant :WAKE
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
def register(runner, pid)
|
|
130
|
+
mutex.synchronize do
|
|
131
|
+
install unless entries.any?
|
|
132
|
+
entries[runner] = Entry.new(runner, pid)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def unregister(runner)
|
|
137
|
+
worker = mutex.synchronize do
|
|
138
|
+
next unless entries.delete(runner)
|
|
139
|
+
|
|
140
|
+
restore unless entries.any?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
worker.join if worker && worker != Thread.current
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def dispatch
|
|
149
|
+
active = mutex.synchronize { entries.values.dup }
|
|
150
|
+
active.each { dispatch_entry(it) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dispatch_entry(entry)
|
|
154
|
+
entry.runner.send(:handle_sigint, entry.pid)
|
|
155
|
+
rescue ::StandardError => e
|
|
156
|
+
warn("SIGINT dispatch failed: #{e.message}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def install
|
|
160
|
+
reader, writer = ::IO.pipe
|
|
161
|
+
worker = Thread.new { listen(reader) }
|
|
162
|
+
previous = Signal.trap("INT") { notify(writer) }
|
|
163
|
+
|
|
164
|
+
@previous = previous
|
|
165
|
+
@worker = worker
|
|
166
|
+
@writer = writer
|
|
167
|
+
rescue ::StandardError
|
|
168
|
+
Signal.trap("INT", previous) if previous
|
|
169
|
+
close_io(writer)
|
|
170
|
+
worker&.join
|
|
171
|
+
close_io(reader)
|
|
172
|
+
raise
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def listen(reader)
|
|
176
|
+
dispatch while reader.read(1)
|
|
177
|
+
rescue ::IOError, ::SystemCallError
|
|
178
|
+
nil
|
|
179
|
+
ensure
|
|
180
|
+
close_io(reader)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def mutex = @mutex ||= Mutex.new
|
|
184
|
+
|
|
185
|
+
def entries = @entries ||= {}
|
|
186
|
+
|
|
187
|
+
def notify(writer)
|
|
188
|
+
writer.write_nonblock(WAKE, exception: false)
|
|
189
|
+
rescue ::IOError, ::SystemCallError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def restore
|
|
194
|
+
Signal.trap("INT", @previous)
|
|
195
|
+
@previous = nil
|
|
196
|
+
|
|
197
|
+
writer = @writer
|
|
198
|
+
worker = @worker
|
|
199
|
+
@worker = nil
|
|
200
|
+
@writer = nil
|
|
201
|
+
close_io(writer)
|
|
202
|
+
worker
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def close_io(io)
|
|
206
|
+
io&.close unless io&.closed?
|
|
207
|
+
rescue ::IOError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private_constant :Signals
|
|
214
|
+
|
|
76
215
|
# Runs shell commands and captures stdout, stderr, and exit status.
|
|
77
216
|
# @api private
|
|
78
217
|
class Runner
|
|
@@ -87,24 +226,26 @@ module Sevgi
|
|
|
87
226
|
# @yield optional content writer for stdin
|
|
88
227
|
# @yieldreturn [String, nil]
|
|
89
228
|
# @return [Sevgi::Function::Shell::Result]
|
|
229
|
+
# @raise [Sevgi::ArgumentError] when no command is given
|
|
90
230
|
# @raise [Errno::ENOENT] when the executable cannot be spawned
|
|
91
231
|
def call(*args, &input)
|
|
92
|
-
|
|
232
|
+
ArgumentError.("Shell command required") if args.empty?
|
|
93
233
|
|
|
94
234
|
@coathooks = 0
|
|
95
235
|
outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
|
|
96
236
|
capture(stdin, stdout, stderr, wait_thread, &input)
|
|
97
237
|
end
|
|
98
238
|
|
|
99
|
-
Result.new(args
|
|
239
|
+
Result.new(args:, outs:, errs:, exit_code: status.exitstatus, signal: status.termsig)
|
|
100
240
|
end
|
|
101
241
|
|
|
102
242
|
private
|
|
103
243
|
|
|
104
244
|
# rubocop:disable Lint/RescueException
|
|
105
245
|
def capture(stdin, stdout, stderr, wait_thread, &input)
|
|
106
|
-
|
|
107
|
-
|
|
246
|
+
registered = false
|
|
247
|
+
Signals.register(self, wait_thread.pid)
|
|
248
|
+
registered = true
|
|
108
249
|
readers = start_readers(stdout, stderr)
|
|
109
250
|
|
|
110
251
|
read_process(stdin, wait_thread, readers, &input)
|
|
@@ -113,7 +254,7 @@ module Sevgi
|
|
|
113
254
|
raise
|
|
114
255
|
ensure
|
|
115
256
|
close_input(stdin)
|
|
116
|
-
|
|
257
|
+
Signals.unregister(self) if registered
|
|
117
258
|
end
|
|
118
259
|
# rubocop:enable Lint/RescueException
|
|
119
260
|
|
|
@@ -189,16 +330,20 @@ module Sevgi
|
|
|
189
330
|
end
|
|
190
331
|
end
|
|
191
332
|
|
|
333
|
+
private_constant :Runner
|
|
334
|
+
|
|
192
335
|
# @overload sh(*args, &block)
|
|
193
336
|
# Runs a command and captures stdout, stderr, and exit status.
|
|
194
337
|
# @param args [Array<String>] command and arguments
|
|
195
338
|
# @yield optional stdin producer, evaluated once after output readers start
|
|
196
339
|
# @yieldreturn [String, nil] content to write to stdin; nil writes nothing
|
|
197
340
|
# @return [Sevgi::Function::Shell::Result]
|
|
341
|
+
# @raise [Sevgi::ArgumentError] when no command is given
|
|
198
342
|
# @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
|
|
199
343
|
# @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
|
|
200
344
|
# @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
|
|
345
|
+
# child process and the second SIGINT as KILL to each active child outside trap context, then restores the
|
|
346
|
+
# previous handler.
|
|
202
347
|
def sh(...) = Runner.new.(...)
|
|
203
348
|
|
|
204
349
|
# Runs a command, requiring both executable lookup and successful exit status.
|
|
@@ -206,11 +351,13 @@ module Sevgi
|
|
|
206
351
|
# @yield optional stdin producer, evaluated once after output readers start
|
|
207
352
|
# @yieldreturn [String, nil] content to write to stdin; nil writes nothing
|
|
208
353
|
# @return [Sevgi::Function::Shell::Result]
|
|
354
|
+
# @raise [Sevgi::ArgumentError] when no command is given
|
|
209
355
|
# @raise [Sevgi::Error] when the executable is missing or the command fails
|
|
210
356
|
# @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
|
|
211
357
|
# @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
|
|
212
358
|
# @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
|
|
359
|
+
# child process and the second SIGINT as KILL to each active child outside trap context, then restores the previous
|
|
360
|
+
# handler.
|
|
214
361
|
def sh!(*args, &block)
|
|
215
362
|
executable!(*args) unless args.empty?
|
|
216
363
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Function
|
|
5
|
-
# String
|
|
5
|
+
# String methods promoted to {Sevgi::F}. This module organizes the facade implementation; it is not a consumer mixin
|
|
6
|
+
# contract.
|
|
6
7
|
module String
|
|
7
8
|
# Returns the final constant name segment from a module path.
|
|
8
9
|
# @param path [Object] module, class, or string-like path
|
|
@@ -19,9 +20,11 @@ module Sevgi
|
|
|
19
20
|
|
|
20
21
|
extend String
|
|
21
22
|
|
|
22
|
-
#
|
|
23
|
+
# English pluralization promoted to {Sevgi::F}. This module organizes the facade implementation; it is not a
|
|
24
|
+
# consumer mixin contract.
|
|
23
25
|
module Pluralize
|
|
24
26
|
# Words that should not be pluralized.
|
|
27
|
+
# @api private
|
|
25
28
|
UNCOUNTABLES = %w[
|
|
26
29
|
equipment
|
|
27
30
|
fish
|
|
@@ -36,6 +39,7 @@ module Sevgi
|
|
|
36
39
|
.freeze
|
|
37
40
|
|
|
38
41
|
# Singular-to-plural forms that do not follow suffix rules.
|
|
42
|
+
# @api private
|
|
39
43
|
IRREGULARS = Hash[
|
|
40
44
|
*%w[
|
|
41
45
|
child
|
|
@@ -59,9 +63,11 @@ module Sevgi
|
|
|
59
63
|
.freeze
|
|
60
64
|
|
|
61
65
|
# Plural forms already accepted as plural.
|
|
66
|
+
# @api private
|
|
62
67
|
PLURALS = IRREGULARS.invert.freeze
|
|
63
68
|
|
|
64
69
|
# Ordered suffix replacement rules.
|
|
70
|
+
# @api private
|
|
65
71
|
RULES = [
|
|
66
72
|
[/(quiz)$/i, "\\1zes"],
|
|
67
73
|
[/^(oxen)$/i, "\\1"],
|
|
@@ -84,16 +90,20 @@ module Sevgi
|
|
|
84
90
|
[/^(ax|test)is$/i, "\\1es"],
|
|
85
91
|
[/s$/i, "s"],
|
|
86
92
|
[/$/, "s"]
|
|
87
|
-
]
|
|
93
|
+
]
|
|
94
|
+
.each(&:freeze)
|
|
95
|
+
.freeze
|
|
96
|
+
|
|
97
|
+
private_constant :IRREGULARS, :PLURALS, :RULES, :UNCOUNTABLES
|
|
88
98
|
|
|
89
99
|
# Pluralizes an English word using a small built-in rule set.
|
|
90
100
|
# @param word [Object] word to pluralize
|
|
91
101
|
# @return [String]
|
|
92
|
-
# @example
|
|
93
|
-
# F.pluralize("post") # => "posts"
|
|
94
|
-
# F.pluralize("octopus") # => "octopi"
|
|
95
|
-
# F.pluralize("sheep") # => "sheep"
|
|
96
|
-
# F.pluralize("CamelOctopus") # => "CamelOctopi"
|
|
102
|
+
# @example Pluralize through the library facade
|
|
103
|
+
# Sevgi::F.pluralize("post") # => "posts"
|
|
104
|
+
# Sevgi::F.pluralize("octopus") # => "octopi"
|
|
105
|
+
# Sevgi::F.pluralize("sheep") # => "sheep"
|
|
106
|
+
# Sevgi::F.pluralize("CamelOctopus") # => "CamelOctopi"
|
|
97
107
|
def pluralize(word)
|
|
98
108
|
result = word.to_s.dup
|
|
99
109
|
|
data/lib/sevgi/function/ui.rb
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Function
|
|
5
|
-
#
|
|
5
|
+
# Terminal status methods promoted to {Sevgi::F}. This module organizes the facade implementation; it is not a
|
|
6
|
+
# consumer mixin contract.
|
|
6
7
|
module UI
|
|
7
8
|
# Reports an in-progress status message.
|
|
8
9
|
# @param message [Object] status message
|
data/lib/sevgi/function.rb
CHANGED
|
@@ -13,13 +13,23 @@ require_relative "function/ui"
|
|
|
13
13
|
require_relative "function/version"
|
|
14
14
|
|
|
15
15
|
module Sevgi
|
|
16
|
-
#
|
|
16
|
+
# Support toolbox for Sevgi components and advanced extensions. The supported helper facade is {Sevgi::F}; it
|
|
17
|
+
# provides degree-based trigonometry and precision, file discovery and output, argv-safe commands, naming helpers,
|
|
18
|
+
# and small terminal status tools. It is not intended as a general-purpose utility library.
|
|
17
19
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
+
# {Function::Location}, {Function::Locate}, and {Function::Shell::Result} are public supporting values. The
|
|
21
|
+
# thread-local precision accessors remain on {Function::Math.precision}. Other nested helper modules organize the
|
|
22
|
+
# facade implementation and its method documentation; consumers should not include or extend them.
|
|
23
|
+
#
|
|
24
|
+
# @example Use the supported facade in library code
|
|
25
|
+
# Sevgi::F.with_precision(3) do
|
|
26
|
+
# Sevgi::F.cos(60)
|
|
27
|
+
# Sevgi::F.approx(1.0 / 3)
|
|
28
|
+
# end
|
|
29
|
+
# @see https://sevgi.roktas.dev/functions/ Function toolbox guide
|
|
20
30
|
module Function
|
|
21
31
|
end
|
|
22
32
|
|
|
23
|
-
#
|
|
33
|
+
# Supported helper facade for Sevgi extensions and advanced consumers.
|
|
24
34
|
F = Function unless defined?(F)
|
|
25
35
|
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.98.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Recai Oktaş
|
|
@@ -9,7 +9,7 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
12
|
+
description: Collects numeric, string, file, shell, and terminal helpers.
|
|
13
13
|
email: roktas@gmail.com
|
|
14
14
|
executables: []
|
|
15
15
|
extensions: []
|
|
@@ -50,7 +50,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
51
|
version: '0'
|
|
52
52
|
requirements: []
|
|
53
|
-
rubygems_version: 4.0.
|
|
53
|
+
rubygems_version: 4.0.16
|
|
54
54
|
specification_version: 4
|
|
55
|
-
summary:
|
|
55
|
+
summary: Shared utility functions for Sevgi components.
|
|
56
56
|
test_files: []
|