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.
@@ -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
- # Shell execution helpers and executable lookup utilities.
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.split.first
31
+ program = args.first.to_s
29
32
  Error.("Missing executable: #{program}") unless executable?(program)
30
33
  end
31
34
 
32
- # Result object returned by shell commands.
33
- Result = Struct.new(:args, :outs, :errs, :exit_code) do
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
- # Returns stdout, a separator, and stderr as one string.
44
- # @return [String]
45
- def all = [*outs, "\n\n", *errs].join("\n").strip
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 = args.join(" ")
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&.zero?
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
- # Builds a successful empty result.
72
- # @return [Sevgi::Function::Shell::Result]
73
- def self.dummy = new([], [], [], 0)
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
- return Result.dummy if args.empty?
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, outs, errs, status.exitstatus)
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
- # Handle `^C`
107
- previous = trap("INT") { handle_sigint(wait_thread.pid) }
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
- trap("INT", previous) if previous
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 sends KILL; the previous SIGINT handler is restored before return.
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 sends KILL; the previous SIGINT handler is restored before return.
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 helpers used by generated names and user-facing text.
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
- # Lightweight English pluralization helper.
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
- ].freeze
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
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
- # Small terminal status helpers used by build and release tasks.
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
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Function
5
5
  # Current version of the Sevgi function gem.
6
- VERSION = "0.94.0"
6
+ VERSION = "0.98.2"
7
7
  end
8
8
  end
@@ -13,13 +13,23 @@ 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}.
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
- # @example Use helper methods through the public alias
19
- # F.pluralize("axis")
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
- # Public alias for the shared function helper namespace.
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.94.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: Enhances the Sevgi toolkit with utility methods.
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.10
53
+ rubygems_version: 4.0.16
54
54
  specification_version: 4
55
- summary: Various utility functions for the Sevgi toolkit.
55
+ summary: Shared utility functions for Sevgi components.
56
56
  test_files: []