sevgi-function 0.93.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 060b3cdf233db4debfb3429c2d8663d94840ae00de70980124089f2d5d04c675
4
- data.tar.gz: 2f80215534863a248171055a829ddcfa36ccf73013fbd430064b04fade3eeb5f
3
+ metadata.gz: 7afe91e24bcfcbc6b2e398838f8cf9cce6fdb97b9c7b7cded831a6f892c75280
4
+ data.tar.gz: 505afa54ddc3e02573ed7169ca6aa01f14a90bd3c57d41bafe0cb87bb7b47e46
5
5
  SHA512:
6
- metadata.gz: e4de61331c47e0007652e6c7548adf6de50e8d4ded0e1bc95c05dc9d7a0fc9929ee4ffdd3af3b3cd73eddc777495691c4952186e3f0f34390edcee062f0e907b
7
- data.tar.gz: a36e58998da54d4c687e9266e1c58290af919ab3e4229627ab2d4c5a9b0ec4bd7a04a053ef8e8779ea3ef51268c1add54c75328475904562267d7073e683fa3f
6
+ metadata.gz: dd9066dc9cc025ff15122265b66ace4861a62b150a10ac72addd1255bf3223337cd1ad3c6e0259423abc184a98db97cb0f5c0e0769224240e20db8ae8654ee41
7
+ data.tar.gz: 0d47888b1cce2c39fba38c09ead6044401908ae48d789ff596ee0cb7055c2225cab69b094803c84a8a8d1737d4d2a3268e1f769154e9770c55c01e9813f6160e
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Function follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Function is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Sevgi Function
2
2
 
3
- Provides shared helper functions used by Sevgi components.
3
+ Shared helper functions used by Sevgi components.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
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
@@ -45,21 +45,6 @@ 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
@@ -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,78 @@ 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::Locate::Location, nil] found location, or nil
25
+ # @return [Sevgi::Function::Location, nil] found location, or nil
16
26
  def self.call(*, **, &block) = new(*, **).call(&block)
17
27
 
18
- Location = Data.define(:file, :slug, :dir)
19
-
20
- private_constant :Location
21
-
22
28
  # @!attribute [r] paths
23
29
  # @return [Array<String>] candidate paths
24
30
  # @!attribute [r] start
25
- # @return [String] start directory
31
+ # @return [String] absolute start directory
26
32
  # @!attribute [r] exclude
27
33
  # @return [Array<String>, nil] expanded paths ignored during lookup
28
34
  attr_reader :paths, :start, :exclude
29
35
 
30
36
  # Builds an upward file locator.
31
37
  # @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
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
34
40
  # @return [void]
35
41
  def initialize(paths, start = ::Dir.pwd, exclude: nil)
36
42
  @paths = Array(paths)
37
- @start = start
43
+ @start = ::File.expand_path(start)
38
44
  @exclude = [*exclude].map { ::File.expand_path(it) } unless exclude.nil?
39
45
  end
40
46
 
41
47
  # Runs the upward lookup.
42
48
  # @yield optional matcher used instead of file existence checks
43
- # @yieldparam path [String] candidate path
49
+ # @yieldparam path [String] absolute candidate path
44
50
  # @yieldreturn [Boolean]
45
- # @return [Sevgi::Function::Locate::Location, nil] found location, or nil
46
- # @raise [Errno::ENOENT] when the start directory cannot be entered
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
47
54
  def call(&block)
48
- origin = ::Dir.pwd
49
- ::Dir.chdir(start)
55
+ validate_start!
50
56
 
51
- here = ::Dir.pwd
52
- until (found = match(&block))
53
- ::Dir.chdir("..")
54
- ::Dir.pwd == here ? return : here = ::Dir.pwd
55
- end
57
+ each_parent(start) do |here|
58
+ next unless (found = match(here, &block))
59
+
60
+ slug, file = found
56
61
 
57
- Location[::File.expand_path(found, here), found, here]
58
- ensure
59
- ::Dir.chdir(origin)
62
+ return Location[file, slug, here]
63
+ end
60
64
  end
61
65
 
62
66
  private
63
67
 
64
- def match(&block)
65
- finder = block ||
66
- if exclude.nil?
67
- proc { |path| ::File.exist?(path) }
68
- else
69
- proc { |path| !exclude.include?(::File.expand_path(path)) && ::File.exist?(path) }
70
- end
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
71
93
 
72
- paths.find { finder.call(it) }
94
+ def validate_start!
95
+ raise ::Errno::ENOENT, start unless ::File.exist?(start)
96
+ raise ::Errno::ENOTDIR, start unless ::File.directory?(start)
73
97
  end
74
98
  end
75
99
 
@@ -78,7 +102,7 @@ module Sevgi
78
102
  # @param start [String] directory where lookup starts
79
103
  # @param exclude [Array<String>, String, nil] paths ignored during lookup
80
104
  # @param extension [String] default extension added before lookup
81
- # @return [Sevgi::Function::Locate::Location] found location
105
+ # @return [Sevgi::Function::Location] found location
82
106
  # @raise [Sevgi::Error] when no matching file exists
83
107
  def self.locate(filename, start, exclude: nil, extension: EXTENSION)
84
108
  Locate.(F.qualify(filename, extension), start, exclude:).tap do |path|
@@ -77,8 +77,13 @@ module Sevgi
77
77
  # @param length [Numeric] total length
78
78
  # @param division [Numeric] division size
79
79
  # @return [Integer]
80
- # @raise [ZeroDivisionError] when division is zero
81
- def count(length, division) = (length / division.to_f).to_i
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
82
87
 
83
88
  # Compares two numeric values after approximate rounding.
84
89
  # @param left [Numeric] left operand
@@ -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 in PATH.
10
- # @param program [Object] program name
11
- # @return [Boolean] true when an executable with this name is found
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
- executable_cache.fetch(program) do
17
- executable_cache[program] = ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR).any? do |dir|
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
 
@@ -91,12 +91,9 @@ module Sevgi
91
91
  def call(*args, &input)
92
92
  return Result.dummy if args.empty?
93
93
 
94
+ @coathooks = 0
94
95
  outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
95
- content = input.call if input
96
- stdin.write(content) if content
97
- stdin.close
98
-
99
- capture(stdout, stderr, wait_thread)
96
+ capture(stdin, stdout, stderr, wait_thread, &input)
100
97
  end
101
98
 
102
99
  Result.new(args, outs, errs, status.exitstatus)
@@ -104,19 +101,47 @@ module Sevgi
104
101
 
105
102
  private
106
103
 
107
- def capture(stdout, stderr, wait_thread)
104
+ # rubocop:disable Lint/RescueException
105
+ def capture(stdin, stdout, stderr, wait_thread, &input)
108
106
  # Handle `^C`
109
107
  previous = trap("INT") { handle_sigint(wait_thread.pid) }
108
+ readers = start_readers(stdout, stderr)
110
109
 
111
- outs = Thread.new { stdout.readlines.map(&:chomp) }
112
- errs = Thread.new { stderr.readlines.map(&:chomp) }
113
-
114
- [outs.value, errs.value, wait_thread.value]
110
+ read_process(stdin, wait_thread, readers, &input)
111
+ rescue Exception
112
+ cleanup_failed_capture(stdin, wait_thread, readers)
113
+ raise
115
114
  ensure
115
+ close_input(stdin)
116
116
  trap("INT", previous) if previous
117
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
126
+
127
+ def read_process(stdin, wait_thread, readers, &input)
128
+ write_input(stdin, &input)
129
+ close_input(stdin)
130
+
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)
140
+ end
118
141
 
119
142
  def handle_sigint(pid)
143
+ @coathooks += 1
144
+
120
145
  message, signal = if @coathooks > 1
121
146
  ["SIGINT received again. Force quitting...", "KILL"]
122
147
  else
@@ -126,28 +151,66 @@ module Sevgi
126
151
  warn
127
152
  warn(message)
128
153
  ::Process.kill(signal, pid)
129
- @coathooks += 1
130
154
  rescue Errno::ESRCH
131
155
  warn("No process to kill.")
132
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
133
190
  end
134
191
 
135
192
  # @overload sh(*args, &block)
136
193
  # Runs a command and captures stdout, stderr, and exit status.
137
194
  # @param args [Array<String>] command and arguments
138
- # @yield optional content writer for stdin
139
- # @yieldreturn [String, nil]
195
+ # @yield optional stdin producer, evaluated once after output readers start
196
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
140
197
  # @return [Sevgi::Function::Shell::Result]
141
- # @raise [Errno::ENOENT] when the executable cannot be spawned
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.
142
202
  def sh(...) = Runner.new.(...)
143
203
 
144
204
  # Runs a command, requiring both executable lookup and successful exit status.
145
205
  # @param args [Array<String>] command and arguments
146
- # @yield optional content writer for stdin
147
- # @yieldreturn [String, nil]
206
+ # @yield optional stdin producer, evaluated once after output readers start
207
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
148
208
  # @return [Sevgi::Function::Shell::Result]
149
209
  # @raise [Sevgi::Error] when the executable is missing or the command fails
150
- # @raise [Errno::ENOENT] when the executable cannot be spawned
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.
151
214
  def sh!(*args, &block)
152
215
  executable!(*args) unless args.empty?
153
216
 
@@ -163,7 +226,13 @@ module Sevgi
163
226
 
164
227
  private
165
228
 
166
- def executable_cache = @executable_cache ||= {}
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
167
236
  end
168
237
 
169
238
  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 = Hash[
26
- *%w[
27
- sheep
28
- fish
29
- sheep
30
- series
31
- species
32
- money
33
- rice
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 word.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
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) }
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Function
5
5
  # Current version of the Sevgi function gem.
6
- VERSION = "0.93.1"
6
+ VERSION = "0.94.0"
7
7
  end
8
8
  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.93.1
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.pre.preview1
46
+ version: 3.4.0
45
47
  required_rubygems_version: !ruby/object:Gem::Requirement
46
48
  requirements:
47
49
  - - ">="