graphomaton 1.0.0 → 1.2.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -8
  3. data/README.md +427 -44
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +28 -0
  7. data/docs/custom-exporters.md +39 -0
  8. data/docs/exporters.md +19 -0
  9. data/docs/input-schema.md +27 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +30 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +863 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +130 -0
  19. data/lib/graphomaton/exporters/dot.rb +255 -18
  20. data/lib/graphomaton/exporters/mermaid.rb +705 -25
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +250 -13
  23. data/lib/graphomaton/exporters/png.rb +176 -0
  24. data/lib/graphomaton/exporters/svg.rb +2808 -231
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +11 -4
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +83 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +230 -0
  31. data/lib/graphomaton/process_runner.rb +153 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2933 -54
  35. data/sig/graphomaton.rbs +129 -0
  36. metadata +38 -25
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -30
  42. data/sample/complex.rb +0 -32
  43. data/sample/long_names.rb +0 -20
  44. data/sample/nfa.rb +0 -28
  45. data/sample/skip_states.rb +0 -23
  46. data/spec/exporters/dot_spec.rb +0 -146
  47. data/spec/exporters/mermaid_spec.rb +0 -154
  48. data/spec/exporters/plantuml_spec.rb +0 -144
  49. data/spec/exporters/svg_spec.rb +0 -314
  50. data/spec/graphomaton_edge_cases_spec.rb +0 -322
  51. data/spec/graphomaton_spec.rb +0 -371
  52. data/spec/spec_helper.rb +0 -13
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+
6
+ class Graphomaton
7
+ class ProcessRunner
8
+ class Error < Graphomaton::Error; end
9
+ class TimeoutError < Error; end
10
+ class OutputLimitError < Error; end
11
+
12
+ DEFAULT_TIMEOUT = 30
13
+ DEFAULT_MAX_STDOUT_BYTES = 64 * 1024 * 1024
14
+ DEFAULT_MAX_STDERR_BYTES = 1024 * 1024
15
+ READ_SIZE = 16 * 1024
16
+
17
+ def self.which(command, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', ''))
18
+ name = command.to_s
19
+ return nil if name.empty? || name.include?("\0")
20
+
21
+ explicit = name.include?(File::SEPARATOR) || (File::ALT_SEPARATOR && name.include?(File::ALT_SEPARATOR))
22
+ directories = explicit ? [''] : path.split(File::PATH_SEPARATOR)
23
+ extensions = executable_extensions(name, pathext)
24
+ directories.each do |directory|
25
+ base = explicit ? name : File.join(directory, name)
26
+ extensions.each do |extension|
27
+ candidate = "#{base}#{extension}"
28
+ return File.expand_path(candidate) if File.file?(candidate) && File.executable?(candidate)
29
+ end
30
+ end
31
+ nil
32
+ end
33
+
34
+ def self.capture3(*command, stdin_data: '', binmode: false, timeout: DEFAULT_TIMEOUT,
35
+ max_stdout_bytes: DEFAULT_MAX_STDOUT_BYTES,
36
+ max_stderr_bytes: DEFAULT_MAX_STDERR_BYTES)
37
+ validate_limit(timeout, 'timeout')
38
+ validate_limit(max_stdout_bytes, 'max_stdout_bytes')
39
+ validate_limit(max_stderr_bytes, 'max_stderr_bytes')
40
+
41
+ spawn_options = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
42
+ stdout_data = nil
43
+ stderr_data = nil
44
+ status = nil
45
+
46
+ Open3.popen3(*command, **spawn_options) do |stdin, stdout, stderr, wait_thread|
47
+ streams = [stdin, stdout, stderr]
48
+ streams.each(&:binmode) if binmode
49
+ writer = input_writer(stdin, stdin_data)
50
+ stdout_reader = output_reader(stdout, max_stdout_bytes, 'stdout')
51
+ stderr_reader = output_reader(stderr, max_stderr_bytes, 'stderr')
52
+ threads = [writer, stdout_reader, stderr_reader]
53
+
54
+ begin
55
+ Timeout.timeout(timeout, TimeoutError, "Process timed out after #{timeout} seconds") do
56
+ writer.value
57
+ stdout_data = stdout_reader.value
58
+ stderr_data = stderr_reader.value
59
+ status = wait_thread.value
60
+ end
61
+ rescue TimeoutError, OutputLimitError
62
+ terminate(wait_thread)
63
+ raise
64
+ ensure
65
+ streams.each { |stream| stream.close unless stream.closed? }
66
+ threads.each do |thread|
67
+ thread.kill if thread.alive?
68
+ thread.join
69
+ end
70
+ end
71
+ end
72
+
73
+ [stdout_data, stderr_data, status]
74
+ end
75
+
76
+ def self.input_writer(stdin, data)
77
+ Thread.new do
78
+ Thread.current.report_on_exception = false
79
+ begin
80
+ stdin.write(data)
81
+ rescue Errno::EPIPE, IOError
82
+ nil
83
+ ensure
84
+ stdin.close unless stdin.closed?
85
+ end
86
+ end
87
+ end
88
+ private_class_method :input_writer
89
+
90
+ def self.output_reader(stream, limit, name)
91
+ Thread.new do
92
+ Thread.current.report_on_exception = false
93
+ output = String.new(encoding: Encoding::BINARY)
94
+
95
+ begin
96
+ loop do
97
+ chunk = stream.readpartial(READ_SIZE)
98
+ if output.bytesize + chunk.bytesize > limit
99
+ raise OutputLimitError, "Process #{name} exceeded #{limit} bytes"
100
+ end
101
+ output << chunk
102
+ end
103
+ rescue EOFError, IOError
104
+ output
105
+ ensure
106
+ stream.close unless stream.closed?
107
+ end
108
+ end
109
+ end
110
+ private_class_method :output_reader
111
+
112
+ def self.terminate(wait_thread)
113
+ pid = wait_thread.pid
114
+ parent_alive = wait_thread.alive?
115
+ signal_process(pid, 'TERM')
116
+ parent_alive ? wait_thread.join(0.25) : sleep(0.25)
117
+ signal_process(pid, 'KILL')
118
+ wait_thread.join if wait_thread.alive?
119
+ end
120
+ private_class_method :terminate
121
+
122
+ def self.signal_process(pid, signal)
123
+ attempts = Gem.win_platform? ? [[9, pid]] : [[signal, -pid], [signal, pid]]
124
+ attempts.each do |candidate_signal, target|
125
+ begin
126
+ Process.kill(candidate_signal, target)
127
+ return
128
+ rescue Errno::ESRCH, Errno::ECHILD
129
+ return
130
+ rescue Errno::EINVAL, Errno::EPERM
131
+ next
132
+ end
133
+ end
134
+ nil
135
+ end
136
+ private_class_method :signal_process
137
+
138
+ def self.validate_limit(value, name)
139
+ return if value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
140
+
141
+ raise ArgumentError, "#{name} must be a positive finite number"
142
+ end
143
+ private_class_method :validate_limit
144
+
145
+ def self.executable_extensions(command, pathext)
146
+ return [''] unless Gem.win_platform? && File.extname(command).empty?
147
+
148
+ extensions = pathext.empty? ? %w[.COM .EXE .BAT .CMD] : pathext.split(';').reject(&:empty?)
149
+ extensions.flat_map { |extension| [extension, extension.downcase] }.uniq
150
+ end
151
+ private_class_method :executable_extensions
152
+ end
153
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ class Graphomaton
6
+ class UrlPolicy
7
+ LINK_SCHEMES = %w[http https mailto].freeze
8
+ ASSET_SCHEMES = %w[https].freeze
9
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
10
+
11
+ def self.validate(value, context: 'URL', schemes: LINK_SCHEMES, allow_windows_path: false)
12
+ url = value.to_s
13
+ unless valid?(url, schemes: schemes, allow_windows_path: allow_windows_path)
14
+ raise SecurityError, "Unsafe #{context}: #{value.inspect}"
15
+ end
16
+
17
+ url
18
+ end
19
+
20
+ def self.validate_asset(value, context: 'asset URL')
21
+ validate(value, context: context, schemes: ASSET_SCHEMES, allow_windows_path: true)
22
+ end
23
+
24
+ def self.valid?(url, schemes: LINK_SCHEMES, allow_windows_path: false)
25
+ return false if url.empty? || url != url.strip
26
+ return false if url.match?(CONTROL_CHARACTERS) || url.start_with?('//')
27
+ return true if allow_windows_path && url.match?(/\A[A-Za-z]:[\\\/]/)
28
+ return false if url.include?('\\')
29
+
30
+ uri = URI.parse(url)
31
+ return true unless uri.scheme
32
+
33
+ schemes.include?(uri.scheme.downcase)
34
+ rescue URI::InvalidURIError
35
+ false
36
+ end
37
+
38
+ private_class_method :valid?
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Graphomaton
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end