vectory 0.7.8 → 0.8.1

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +59 -0
  3. data/.github/workflows/links.yml +99 -0
  4. data/.github/workflows/rake.yml +5 -1
  5. data/.github/workflows/release.yml +7 -3
  6. data/.gitignore +5 -0
  7. data/.rubocop.yml +11 -3
  8. data/.rubocop_todo.yml +252 -0
  9. data/Gemfile +4 -2
  10. data/README.adoc +23 -1
  11. data/Rakefile +13 -0
  12. data/docs/Gemfile +18 -0
  13. data/docs/_config.yml +179 -0
  14. data/docs/features/conversion.adoc +205 -0
  15. data/docs/features/external-dependencies.adoc +305 -0
  16. data/docs/features/format-detection.adoc +173 -0
  17. data/docs/features/index.adoc +205 -0
  18. data/docs/getting-started/core-concepts.adoc +214 -0
  19. data/docs/getting-started/index.adoc +37 -0
  20. data/docs/getting-started/installation.adoc +318 -0
  21. data/docs/getting-started/quick-start.adoc +160 -0
  22. data/docs/guides/error-handling.adoc +400 -0
  23. data/docs/guides/index.adoc +197 -0
  24. data/docs/index.adoc +146 -0
  25. data/docs/lychee.toml +25 -0
  26. data/docs/reference/api.adoc +355 -0
  27. data/docs/reference/index.adoc +189 -0
  28. data/docs/understanding/architecture.adoc +277 -0
  29. data/docs/understanding/index.adoc +148 -0
  30. data/docs/understanding/inkscape-wrapper.adoc +270 -0
  31. data/lib/vectory/capture.rb +165 -37
  32. data/lib/vectory/cli.rb +2 -0
  33. data/lib/vectory/configuration.rb +177 -0
  34. data/lib/vectory/conversion/ghostscript_strategy.rb +77 -0
  35. data/lib/vectory/conversion/inkscape_strategy.rb +124 -0
  36. data/lib/vectory/conversion/strategy.rb +58 -0
  37. data/lib/vectory/conversion.rb +104 -0
  38. data/lib/vectory/datauri.rb +1 -1
  39. data/lib/vectory/emf.rb +17 -5
  40. data/lib/vectory/eps.rb +45 -3
  41. data/lib/vectory/errors.rb +25 -0
  42. data/lib/vectory/file_magic.rb +2 -2
  43. data/lib/vectory/ghostscript_wrapper.rb +160 -0
  44. data/lib/vectory/image_resize.rb +98 -12
  45. data/lib/vectory/inkscape_wrapper.rb +205 -0
  46. data/lib/vectory/pdf.rb +76 -0
  47. data/lib/vectory/platform.rb +105 -0
  48. data/lib/vectory/ps.rb +47 -3
  49. data/lib/vectory/svg.rb +46 -3
  50. data/lib/vectory/svg_document.rb +40 -24
  51. data/lib/vectory/system_call.rb +36 -9
  52. data/lib/vectory/vector.rb +3 -23
  53. data/lib/vectory/version.rb +1 -1
  54. data/lib/vectory.rb +16 -11
  55. metadata +34 -3
  56. data/lib/vectory/inkscape_converter.rb +0 -141
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "tmpdir"
5
+ require_relative "system_call"
6
+ require_relative "platform"
7
+
8
+ module Vectory
9
+ class InkscapeWrapper
10
+ include Singleton
11
+
12
+ def self.convert(content:, input_format:, output_format:, output_class:,
13
+ plain: false)
14
+ instance.convert(
15
+ content: content,
16
+ input_format: input_format,
17
+ output_format: output_format,
18
+ output_class: output_class,
19
+ plain: plain,
20
+ )
21
+ end
22
+
23
+ def convert(content:, input_format:, output_format:, output_class:,
24
+ plain: false)
25
+ with_temp_files(content, input_format,
26
+ output_format) do |input_path, output_path|
27
+ exe = inkscape_path_or_raise_error
28
+ exe = external_path(exe)
29
+ input_path = external_path(input_path)
30
+ output_path = external_path(output_path)
31
+
32
+ cmd = build_command(exe, input_path, output_path, output_format, plain)
33
+ # Pass environment to disable display on non-Windows systems
34
+ env = headless_environment
35
+ call = SystemCall.new(cmd, env: env).call
36
+
37
+ actual_output = find_output(input_path, output_format)
38
+ raise_conversion_error(call) unless actual_output
39
+
40
+ output_class.from_path(actual_output)
41
+ end
42
+ end
43
+
44
+ def height(content, format)
45
+ query_integer(content, format, "--query-height")
46
+ end
47
+
48
+ def width(content, format)
49
+ query_integer(content, format, "--query-width")
50
+ end
51
+
52
+ private
53
+
54
+ def inkscape_path_or_raise_error
55
+ inkscape_path or raise(InkscapeNotFoundError,
56
+ "Inkscape missing in PATH, unable to " \
57
+ "convert image. Aborting.")
58
+ end
59
+
60
+ def inkscape_path
61
+ @inkscape_path ||= find_inkscape
62
+ end
63
+
64
+ def find_inkscape
65
+ cmds.each do |cmd|
66
+ extensions.each do |ext|
67
+ Platform.executable_search_paths.each do |path|
68
+ exe = File.join(path, "#{cmd}#{ext}")
69
+
70
+ return exe if File.executable?(exe) && !File.directory?(exe)
71
+ end
72
+ end
73
+ end
74
+
75
+ nil
76
+ end
77
+
78
+ def cmds
79
+ ["inkscapecom", "inkscape"]
80
+ end
81
+
82
+ def extensions
83
+ ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
84
+ end
85
+
86
+ def find_output(source_path, output_extension)
87
+ basenames = [File.basename(source_path, ".*"),
88
+ File.basename(source_path)]
89
+
90
+ paths = basenames.map do |basename|
91
+ "#{File.join(File.dirname(source_path), basename)}.#{output_extension}"
92
+ end
93
+
94
+ paths.find { |p| File.exist?(p) }
95
+ end
96
+
97
+ def raise_conversion_error(call)
98
+ raise Vectory::ConversionError,
99
+ "Could not convert with Inkscape. " \
100
+ "Inkscape cmd: '#{call.cmd}',\n" \
101
+ "status: '#{call.status}',\n" \
102
+ "stdout: '#{call.stdout.strip}',\n" \
103
+ "stderr: '#{call.stderr.strip}'."
104
+ end
105
+
106
+ def build_command(exe, input_path, output_path, output_format, plain)
107
+ # Modern Inkscape (1.0+) uses --export-filename
108
+ # Older versions use --export-<format>-file or --export-type
109
+ if inkscape_version_modern?
110
+ cmd = "#{exe} --export-filename=#{output_path}"
111
+ cmd += " --export-plain-svg" if plain && output_format == :svg
112
+ # For PDF input, specify which page to use (avoid interactive prompt)
113
+ cmd += " --export-page=1" if input_path.end_with?(".pdf")
114
+ else
115
+ # Legacy Inkscape (0.x) uses --export-type
116
+ cmd = "#{exe} --export-type=#{output_format}"
117
+ cmd += " --export-plain-svg" if plain && output_format == :svg
118
+ end
119
+ cmd += " #{input_path}"
120
+ cmd
121
+ end
122
+
123
+ def inkscape_version_modern?
124
+ return @inkscape_version_modern if defined?(@inkscape_version_modern)
125
+
126
+ exe = inkscape_path
127
+ return @inkscape_version_modern = true unless exe # Default to modern
128
+
129
+ version_output = `#{external_path(exe)} --version 2>&1`
130
+ version_match = version_output.match(/Inkscape (\d+)\./)
131
+
132
+ @inkscape_version_modern = if version_match
133
+ version_match[1].to_i >= 1
134
+ else
135
+ true # Default to modern if we can't detect
136
+ end
137
+ end
138
+
139
+ def with_temp_files(content, input_format, output_format)
140
+ Dir.mktmpdir do |dir|
141
+ input_path = File.join(dir, "image.#{input_format}")
142
+ output_path = File.join(dir, "image.#{output_format}")
143
+ File.binwrite(input_path, content)
144
+
145
+ yield input_path, output_path
146
+ end
147
+ end
148
+
149
+ def query_integer(content, format, options)
150
+ query(content, format, options).to_f.round
151
+ end
152
+
153
+ def query(content, format, options)
154
+ exe = inkscape_path_or_raise_error
155
+
156
+ with_temp_file(content, format) do |path|
157
+ cmd = "#{external_path(exe)} #{options} #{external_path(path)}"
158
+
159
+ # Pass environment to disable display on non-Windows systems
160
+ env = headless_environment
161
+ call = SystemCall.new(cmd, env: env).call
162
+ raise_query_error(call) if call.stdout.empty?
163
+
164
+ call.stdout
165
+ end
166
+ end
167
+
168
+ def with_temp_file(content, extension)
169
+ Dir.mktmpdir do |dir|
170
+ path = File.join(dir, "image.#{extension}")
171
+ File.binwrite(path, content)
172
+
173
+ yield path
174
+ end
175
+ end
176
+
177
+ def raise_query_error(call)
178
+ raise Vectory::InkscapeQueryError,
179
+ "Could not query with Inkscape. " \
180
+ "Inkscape cmd: '#{call.cmd}',\n" \
181
+ "status: '#{call.status}',\n" \
182
+ "stdout: '#{call.stdout.strip}',\n" \
183
+ "stderr: '#{call.stderr.strip}'."
184
+ end
185
+
186
+ # Returns environment variables for headless operation
187
+ # On non-Windows systems, disable DISPLAY to prevent X11/GDK initialization
188
+ def headless_environment
189
+ # On macOS/Linux, disable DISPLAY to prevent Gdk/X11 warnings
190
+ Platform.windows? ? {} : { "DISPLAY" => "" }
191
+ end
192
+
193
+ # Format paths for command execution on current platform
194
+ # Handles Windows backslash conversion and quoting for paths with spaces
195
+ def external_path(path)
196
+ return path unless path
197
+ return path unless Platform.windows?
198
+
199
+ # Convert forward slashes to backslashes
200
+ path.gsub!(%r{/}, "\\")
201
+ # Quote paths with spaces
202
+ path[/\s/] ? "\"#{path}\"" : path
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vector"
4
+ require_relative "inkscape_wrapper"
5
+
6
+ module Vectory
7
+ class Pdf < Vector
8
+ attr_accessor :original_height, :original_width
9
+
10
+ def self.default_extension
11
+ "pdf"
12
+ end
13
+
14
+ def self.mimetype
15
+ "application/pdf"
16
+ end
17
+
18
+ def to_svg
19
+ svg = InkscapeWrapper.convert(
20
+ content: content,
21
+ input_format: :pdf,
22
+ output_format: :svg,
23
+ output_class: Svg,
24
+ plain: true,
25
+ )
26
+
27
+ # If we have original dimensions from EPS/PS, adjust the SVG
28
+ if original_height && original_width
29
+ adjusted_content = adjust_svg_dimensions(svg.content, original_width,
30
+ original_height)
31
+ svg = Svg.new(adjusted_content, svg.initial_path)
32
+ end
33
+
34
+ svg
35
+ end
36
+
37
+ def to_eps
38
+ InkscapeWrapper.convert(
39
+ content: content,
40
+ input_format: :pdf,
41
+ output_format: :eps,
42
+ output_class: Eps,
43
+ )
44
+ end
45
+
46
+ def to_ps
47
+ InkscapeWrapper.convert(
48
+ content: content,
49
+ input_format: :pdf,
50
+ output_format: :ps,
51
+ output_class: Ps,
52
+ )
53
+ end
54
+
55
+ def to_emf
56
+ InkscapeWrapper.convert(
57
+ content: content,
58
+ input_format: :pdf,
59
+ output_format: :emf,
60
+ output_class: Emf,
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def adjust_svg_dimensions(svg_content, width, height)
67
+ # Replace width and height attributes in SVG root element
68
+ svg_content.gsub(/(<svg[^>]*\s)width="[^"]*"/, "\\1width=\"#{width}\"")
69
+ .gsub(/(<svg[^>]*\s)height="[^"]*"/, "\\1height=\"#{height}\"")
70
+ .gsub(/(<svg[^>]*\s)viewBox="[^"]*"/) do |match|
71
+ # Adjust viewBox to match new dimensions
72
+ "#{match.split('viewBox')[0]}viewBox=\"0 0 #{width} #{height}\""
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectory
4
+ # Platform abstraction for centralized OS-specific behavior
5
+ #
6
+ # This class provides a single source of truth for platform detection
7
+ # and platform-specific path handling, eliminating duplicated logic
8
+ # across InkscapeWrapper, GhostscriptWrapper, and other classes.
9
+ #
10
+ # @example Check if running on Windows
11
+ # Vectory::Platform.windows? # => true or false
12
+ #
13
+ # @example Format a path for execution on the current platform
14
+ # Vectory::Platform.path_for_execution("C:/Program Files/Inkscape/inkscape.exe")
15
+ # # On Windows: "C:\\Program Files\\Inkscape\\inkscape.exe"
16
+ # # On Unix: "C:/Program Files/Inkscape/inkscape.exe"
17
+ class Platform
18
+ class << self
19
+ # Detect if running on Windows
20
+ #
21
+ # @return [Boolean] true if on Windows platform
22
+ def windows?
23
+ Gem.win_platform?
24
+ end
25
+
26
+ # Detect if running on macOS
27
+ #
28
+ # @return [Boolean] true if on macOS platform
29
+ def macos?
30
+ RbConfig::CONFIG["host_os"].include?("darwin")
31
+ end
32
+
33
+ # Detect if running on Linux
34
+ #
35
+ # @return [Boolean] true if on Linux platform
36
+ def linux?
37
+ RbConfig::CONFIG["host_os"].include?("linux")
38
+ end
39
+
40
+ # Format a file path for execution on the current platform
41
+ #
42
+ # On Windows, converts forward slashes to backslashes and quotes paths with spaces.
43
+ # On Unix-like systems, returns the path unchanged.
44
+ #
45
+ # @param path [String] the file path to format
46
+ # @return [String] platform-formatted path
47
+ def path_for_execution(path)
48
+ return path unless path
49
+
50
+ formatted_path = windows? ? path.gsub("/", "\\") : path
51
+
52
+ # Quote paths with spaces to prevent shell parsing issues
53
+ formatted_path[/\s/] ? "\"#{formatted_path}\"" : formatted_path
54
+ end
55
+
56
+ # Get the PATH environment variable as an array
57
+ #
58
+ # Handles different PATH separators on Windows (;) vs Unix (:)
59
+ #
60
+ # @return [Array<String>] array of directory paths
61
+ def executable_search_paths
62
+ @executable_search_paths ||= begin
63
+ path_sep = windows? ? ";" : ":"
64
+ (ENV["PATH"] || "").split(path_sep)
65
+ end
66
+ end
67
+
68
+ # Check if a command is available in the system PATH
69
+ #
70
+ # @param command [String] the command to check
71
+ # @return [Boolean] true if command is found in PATH
72
+ def command_available?(command)
73
+ executable_search_paths.any? do |dir|
74
+ executable_path = File.join(dir, command)
75
+ File.executable?(executable_path) && !File.directory?(executable_path)
76
+ end
77
+ end
78
+
79
+ # Get the appropriate shell command extension for the platform
80
+ #
81
+ # @return [String, nil] ".exe" on Windows, nil on Unix
82
+ def command_extension
83
+ windows? ? ".exe" : nil
84
+ end
85
+
86
+ # Get the default shell for the platform
87
+ #
88
+ # @return [String] shell command (e.g., "cmd.exe" on Windows, "sh" on Unix)
89
+ def default_shell
90
+ if windows?
91
+ "cmd.exe"
92
+ else
93
+ "sh"
94
+ end
95
+ end
96
+
97
+ # Reset cached values (primarily for testing)
98
+ #
99
+ # @api private
100
+ def reset_cache
101
+ @executable_search_paths = nil
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/vectory/ps.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ghostscript_wrapper"
4
+ require_relative "pdf"
5
+
3
6
  module Vectory
4
7
  class Ps < Vector
5
8
  def self.default_extension
@@ -20,15 +23,56 @@ module Vectory
20
23
  end
21
24
 
22
25
  def to_eps
23
- convert_with_inkscape("--export-type=eps", Eps)
26
+ to_pdf.to_eps
24
27
  end
25
28
 
26
29
  def to_emf
27
- convert_with_inkscape("--export-type=emf", Emf)
30
+ to_pdf.to_emf
28
31
  end
29
32
 
30
33
  def to_svg
31
- convert_with_inkscape("--export-type=svg", Svg)
34
+ to_pdf.to_svg
35
+ end
36
+
37
+ def to_pdf
38
+ pdf_content = GhostscriptWrapper.convert(content, eps_crop: false)
39
+ pdf = Pdf.new(pdf_content)
40
+ # Pass original BoundingBox dimensions to preserve them in conversions
41
+ bbox = parse_bounding_box
42
+ if bbox
43
+ pdf.original_width = bbox[:urx] - bbox[:llx]
44
+ pdf.original_height = bbox[:ury] - bbox[:lly]
45
+ end
46
+ pdf
47
+ end
48
+
49
+ def height
50
+ bbox = parse_bounding_box
51
+ return super unless bbox
52
+
53
+ bbox[:ury] - bbox[:lly]
54
+ end
55
+
56
+ def width
57
+ bbox = parse_bounding_box
58
+ return super unless bbox
59
+
60
+ bbox[:urx] - bbox[:llx]
61
+ end
62
+
63
+ private
64
+
65
+ def parse_bounding_box
66
+ # Look for %%BoundingBox: llx lly urx ury
67
+ match = content.match(/^%%BoundingBox:\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)/m)
68
+ return nil unless match
69
+
70
+ {
71
+ llx: match[1].to_f,
72
+ lly: match[2].to_f,
73
+ urx: match[3].to_f,
74
+ ury: match[4].to_f,
75
+ }
32
76
  end
33
77
  end
34
78
  end
data/lib/vectory/svg.rb CHANGED
@@ -32,15 +32,58 @@ module Vectory
32
32
  end
33
33
 
34
34
  def to_emf
35
- convert_with_inkscape("--export-type=emf", Emf)
35
+ InkscapeWrapper.convert(
36
+ content: content,
37
+ input_format: :svg,
38
+ output_format: :emf,
39
+ output_class: Emf,
40
+ )
36
41
  end
37
42
 
38
43
  def to_eps
39
- convert_with_inkscape("--export-type=eps", Eps)
44
+ InkscapeWrapper.convert(
45
+ content: content,
46
+ input_format: :svg,
47
+ output_format: :eps,
48
+ output_class: Eps,
49
+ )
40
50
  end
41
51
 
42
52
  def to_ps
43
- convert_with_inkscape("--export-type=ps", Ps)
53
+ InkscapeWrapper.convert(
54
+ content: content,
55
+ input_format: :svg,
56
+ output_format: :ps,
57
+ output_class: Ps,
58
+ )
59
+ end
60
+
61
+ def height
62
+ # Try to read height from SVG attributes first
63
+ doc = Nokogiri::XML(content)
64
+ svg_element = doc.at_xpath("//svg:svg",
65
+ "svg" => SVG_NS) || doc.at_xpath("//svg")
66
+
67
+ if svg_element && svg_element["height"]
68
+ svg_element["height"].to_f.round
69
+ else
70
+ # Fall back to Inkscape query if no height attribute
71
+ super
72
+ end
73
+ end
74
+
75
+ def width
76
+ # Try to read width from SVG attributes first
77
+ doc = Nokogiri::XML(content)
78
+ svg_element = doc.at_xpath("//svg:svg",
79
+ "svg" => SVG_NS) || doc.at_xpath("//svg")
80
+
81
+ if svg_element && svg_element["width"]
82
+ svg_element["width"].to_f.round
83
+ else
84
+ # Fall back to Inkscape query if no width attribute
85
+ super
86
+ end
44
87
  end
45
88
 
46
89
  private
@@ -4,6 +4,44 @@ module Vectory
4
4
  class SvgDocument
5
5
  SVG_NS = "http://www.w3.org/2000/svg".freeze
6
6
 
7
+ class << self
8
+ # Update instances of id in style statements in a nokogiri document
9
+ def update_ids_css(document, ids, suffix)
10
+ suffix = suffix.is_a?(Integer) ? sprintf("%09d", suffix) : suffix
11
+ document.xpath(".//m:style", "m" => SVG_NS).each do |s|
12
+ c = s.children.to_xml
13
+ s.children = update_ids_css_string(c, ids, suffix)
14
+ end
15
+ document.xpath(".//*[@style]").each do |s|
16
+ s["style"] = update_ids_css_string(s["style"], ids, suffix)
17
+ end
18
+ end
19
+
20
+ # Update instances of id in style statements in the string style
21
+ def update_ids_css_string(style, ids, suffix)
22
+ ids.each do |i|
23
+ style = style.gsub(%r[##{i}\b],
24
+ sprintf("#%<id>s_%<suffix>s", id: i,
25
+ suffix: suffix))
26
+ .gsub(%r(\[id\s*=\s*['"]?#{i}['"]?\]),
27
+ sprintf("[id='%<id>s_%<suffix>s']", id: i,
28
+ suffix: suffix))
29
+ end
30
+ style
31
+ end
32
+
33
+ def update_ids_attrs(document, ids, suffix)
34
+ suffix = suffix.is_a?(Integer) ? sprintf("%09d", suffix) : suffix
35
+ document.xpath(". | .//*[@*]").each do |a|
36
+ a.attribute_nodes.each do |x|
37
+ val = x.value.sub(/^#/, "")
38
+ ids.include?(val) and x.value += "_#{suffix}"
39
+ x.value = x.value.sub(%r{url\(#([^()]+)\)}, "url(#\\1_#{suffix})")
40
+ end
41
+ end
42
+ end
43
+ end
44
+
7
45
  def initialize(content)
8
46
  @document = Nokogiri::XML(content)
9
47
  end
@@ -33,8 +71,8 @@ module Vectory
33
71
  ids = collect_ids
34
72
  return if ids.empty?
35
73
 
36
- update_ids_attrs(ids, suffix)
37
- update_ids_css(ids, suffix)
74
+ self.class.update_ids_attrs(@document.root, ids, suffix)
75
+ self.class.update_ids_css(@document.root, ids, suffix)
38
76
 
39
77
  self
40
78
  end
@@ -50,27 +88,5 @@ module Vectory
50
88
  def collect_ids
51
89
  @document.xpath("./@id | .//@id").map(&:value)
52
90
  end
53
-
54
- def update_ids_attrs(ids, suffix)
55
- @document.xpath(". | .//*[@*]").each do |a|
56
- a.attribute_nodes.each do |x|
57
- ids.include?(x.value) and x.value += sprintf("_%09d", suffix)
58
- end
59
- end
60
- end
61
-
62
- def update_ids_css(ids, suffix)
63
- @document.xpath("//m:style", "m" => SVG_NS).each do |s|
64
- c = s.children.to_xml
65
- ids.each do |i|
66
- c = c.gsub(%r[##{i}\b],
67
- sprintf("#%<id>s_%<suffix>09d", id: i, suffix: suffix))
68
- .gsub(%r(\[id\s*=\s*['"]?#{i}['"]?\]),
69
- sprintf("[id='%<id>s_%<suffix>09d']", id: i, suffix: suffix))
70
- end
71
-
72
- s.children = c
73
- end
74
- end
75
91
  end
76
92
  end
@@ -7,15 +7,16 @@ module Vectory
7
7
 
8
8
  attr_reader :status, :stdout, :stderr, :cmd
9
9
 
10
- def initialize(cmd, timeout = TIMEOUT)
10
+ def initialize(cmd, timeout = TIMEOUT, env: {})
11
11
  @cmd = cmd
12
12
  @timeout = timeout
13
+ @env = env
13
14
  end
14
15
 
15
16
  def call
16
17
  log_cmd(@cmd)
17
18
 
18
- execute(@cmd)
19
+ execute(@cmd, @env)
19
20
 
20
21
  log_result
21
22
 
@@ -30,14 +31,26 @@ module Vectory
30
31
  Vectory.ui.debug("Cmd: '#{cmd}'")
31
32
  end
32
33
 
33
- def execute(cmd)
34
- result = Capture.with_timeout(cmd,
35
- timeout: @timeout,
36
- signal: :KILL, # only KILL works on Windows
37
- kill_after: @timeout)
38
- @stdout = result[:stdout]
39
- @stderr = result[:stderr]
34
+ def execute(cmd, env)
35
+ # Build spawn options with environment variables and timeout settings
36
+ spawn_opts = {}
37
+ spawn_opts.merge!(env) if env.any?
38
+ spawn_opts[:timeout] = @timeout
39
+ spawn_opts[:signal] = :TERM # Use TERM on Unix for graceful shutdown
40
+ spawn_opts[:kill_after] = 2
41
+
42
+ # Pass command and options directly (without splatting)
43
+ # Capture.with_timeout expects: cmd, options_hash
44
+ # It will handle extracting the options correctly
45
+ result = if cmd.is_a?(Array)
46
+ Capture.with_timeout(*cmd, spawn_opts)
47
+ else
48
+ Capture.with_timeout(cmd, spawn_opts)
49
+ end
50
+ @stdout = result[:stdout] || ""
51
+ @stderr = result[:stderr] || ""
40
52
  @status = result[:status]
53
+ @timed_out = result[:timeout]
41
54
  rescue Errno::ENOENT => e
42
55
  raise SystemCallError, e.inspect
43
56
  end
@@ -49,6 +62,20 @@ module Vectory
49
62
  end
50
63
 
51
64
  def raise_error
65
+ if @timed_out
66
+ raise SystemCallError,
67
+ "Command timed out after #{@timeout} seconds: #{@cmd},\n " \
68
+ "stdout: '#{@stdout.strip}',\n " \
69
+ "stderr: '#{@stderr.strip}'"
70
+ end
71
+
72
+ if @status.nil?
73
+ raise SystemCallError,
74
+ "Failed to run #{@cmd} (no status available),\n " \
75
+ "stdout: '#{@stdout.strip}',\n " \
76
+ "stderr: '#{@stderr.strip}'"
77
+ end
78
+
52
79
  raise SystemCallError,
53
80
  "Failed to run #{@cmd},\n " \
54
81
  "status: #{@status.exitstatus},\n " \