sevgi 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 +27 -9
- data/bin/igsev +6 -0
- data/bin/sevgi +1 -1
- data/lib/sevgi/binaries/igsev.rb +161 -0
- data/lib/sevgi/binaries/rake.rb +3 -3
- data/lib/sevgi/binaries/sevgi.rb +78 -33
- data/lib/sevgi/executor/error.rb +20 -22
- data/lib/sevgi/executor/result.rb +54 -0
- data/lib/sevgi/executor/scope.rb +46 -14
- data/lib/sevgi/executor/source.rb +21 -4
- data/lib/sevgi/executor.rb +131 -97
- data/lib/sevgi/skill.rb +42 -0
- data/lib/sevgi/svg.rb +137 -0
- data/lib/sevgi/toplevel/derender.rb +92 -6
- data/lib/sevgi/toplevel/executor.rb +56 -14
- data/lib/sevgi/toplevel/graphics.rb +92 -6
- data/lib/sevgi/toplevel/sundries.rb +13 -5
- data/lib/sevgi/toplevel.rb +7 -5
- data/lib/sevgi/version.rb +1 -1
- data/lib/sevgi.rb +100 -13
- metadata +36 -17
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sevgi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `sevgi` gem provides the top-level API and the `.sevgi` script runner.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -17,20 +17,38 @@ require "sevgi"
|
|
|
17
17
|
## Example
|
|
18
18
|
|
|
19
19
|
```ruby
|
|
20
|
-
SVG
|
|
21
|
-
rect
|
|
20
|
+
SVG :minimal do
|
|
21
|
+
rect width: 3, height: 5
|
|
22
22
|
end.call
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Executables
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
28
|
sevgi drawing.sevgi
|
|
29
|
+
igsev drawing.svg
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
Both commands read standard input when the file is omitted or `-`. For `sevgi`, implicit `Save`, `PDF`, and `PNG`
|
|
33
|
+
destinations use `output` as the input name; use `--as NAME` to choose another basename:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
sevgi --as badge < drawing.sevgi
|
|
37
|
+
igsev < drawing.svg > normalized.svg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`NAME` cannot include a directory. An explicit output path or `default:` argument in the script remains authoritative.
|
|
41
|
+
With a file operand, `--as` keeps the file's source directory so relative `Load` calls continue to resolve there.
|
|
42
|
+
|
|
43
|
+
`sevgi --skill` prints the validated path of the matching packaged agent skill. See the
|
|
44
|
+
[Appendix documentation](https://github.com/roktas/sevgi/tree/main/appendix) for installation guidance.
|
|
45
|
+
|
|
46
|
+
`igsev` converts an SVG file to Sevgi source, evaluates it with the complete DSL, and prints the resulting normalized
|
|
47
|
+
SVG. Use `igves` from `sevgi-derender` when the generated Sevgi source itself is the desired output.
|
|
48
|
+
|
|
31
49
|
## Ruby compatibility
|
|
32
50
|
|
|
33
|
-
Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the
|
|
51
|
+
Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
|
|
34
52
|
|
|
35
53
|
## Native prerequisites
|
|
36
54
|
|
|
@@ -55,7 +73,7 @@ gem install cairo rsvg2 hexapdf
|
|
|
55
73
|
|
|
56
74
|
## Links
|
|
57
75
|
|
|
58
|
-
- Documentation: https://sevgi.roktas.dev
|
|
59
|
-
- API documentation: https://www.rubydoc.info/gems/sevgi
|
|
60
|
-
- Source: https://github.com/roktas/sevgi/tree/main/toplevel
|
|
61
|
-
- Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
|
|
76
|
+
- Documentation: <https://sevgi.roktas.dev>
|
|
77
|
+
- API documentation: <https://www.rubydoc.info/gems/sevgi>
|
|
78
|
+
- Source: <https://github.com/roktas/sevgi/tree/main/toplevel>
|
|
79
|
+
- Changelog: <https://github.com/roktas/sevgi/blob/main/CHANGELOG.md>
|
data/bin/igsev
ADDED
data/bin/sevgi
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sevgi"
|
|
4
|
+
|
|
5
|
+
module Sevgi
|
|
6
|
+
# Command-line entrypoint implementations shipped with Sevgi.
|
|
7
|
+
# @api private
|
|
8
|
+
module Binaries
|
|
9
|
+
# Implements the `igsev` executable that round-trips SVG files through Sevgi.
|
|
10
|
+
# @api private
|
|
11
|
+
module Igsev
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
# Executable name used in help output.
|
|
15
|
+
PROGNAME = "igsev"
|
|
16
|
+
|
|
17
|
+
# Error raised for invalid command-line usage.
|
|
18
|
+
Error = Class.new(::Sevgi::Error)
|
|
19
|
+
|
|
20
|
+
FLAGS = {
|
|
21
|
+
"--exception" => :vomit,
|
|
22
|
+
"--help" => :help,
|
|
23
|
+
"--version" => :version,
|
|
24
|
+
"-h" => :help,
|
|
25
|
+
"-v" => :version,
|
|
26
|
+
"-x" => :vomit
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :FLAGS
|
|
29
|
+
|
|
30
|
+
# Parsed command-line options for the `igsev` executable.
|
|
31
|
+
# @api private
|
|
32
|
+
Options = Struct.new(:require, :vomit, :help, :version, :omit) do
|
|
33
|
+
# Parses command-line options and removes them from the argv array.
|
|
34
|
+
# @param argv [Array<String>] mutable command-line argument array
|
|
35
|
+
# @return [Sevgi::Binaries::Igsev::Options] parsed options
|
|
36
|
+
# @raise [Sevgi::Binaries::Igsev::Error] when an option is not recognized or a required value is missing
|
|
37
|
+
def self.parse(argv)
|
|
38
|
+
new.tap do |options|
|
|
39
|
+
until argv.empty? || argv.first == "-" || !argv.first.start_with?("-")
|
|
40
|
+
if argv.first == "--"
|
|
41
|
+
argv.shift
|
|
42
|
+
break
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
option(argv, options)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def option(argv, options)
|
|
54
|
+
arg = argv.shift
|
|
55
|
+
return options[FLAGS[arg]] = true if FLAGS.key?(arg)
|
|
56
|
+
if ["-r", "--require"].include?(arg)
|
|
57
|
+
return options.require = argv.shift || Error.("Option requires a library: #{arg}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return (options.omit ||= []) << (argv.shift || Error.("No attribute given for --omit")) if arg == "--omit"
|
|
61
|
+
|
|
62
|
+
Error.("Not a valid option: #{arg}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private_constant :Options
|
|
68
|
+
|
|
69
|
+
# Runs the `igsev` command-line interface.
|
|
70
|
+
# @param argv [Array<String>, String, nil] command-line arguments
|
|
71
|
+
# @return [nil]
|
|
72
|
+
# @raise [StandardError] when `--exception` or `SEVGI_VOMIT` requests raw errors
|
|
73
|
+
# @raise [SystemExit] when argv does not match `[options...] [--] [file|-]` or conversion aborts
|
|
74
|
+
def call(argv)
|
|
75
|
+
dispatch(Array(argv))
|
|
76
|
+
rescue Binaries::Igsev::Error => e
|
|
77
|
+
abort("#{e.message}\n\n#{help}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def backtrace(error)
|
|
83
|
+
return error.load_backtrace if error.respond_to?(:load_backtrace)
|
|
84
|
+
|
|
85
|
+
error.backtrace || []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def die(error)
|
|
89
|
+
warn(error.message)
|
|
90
|
+
warn("")
|
|
91
|
+
backtrace(error).each { warn(" #{it}") }
|
|
92
|
+
|
|
93
|
+
exit(1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dispatch(argv)
|
|
97
|
+
options = Options.parse(argv)
|
|
98
|
+
return puts(help) if options.help
|
|
99
|
+
return puts(::Sevgi::VERSION) if options.version
|
|
100
|
+
|
|
101
|
+
emit(operand(argv), options)
|
|
102
|
+
rescue Binaries::Igsev::Error
|
|
103
|
+
raise
|
|
104
|
+
rescue ::StandardError => e
|
|
105
|
+
raise if raw_error?(options)
|
|
106
|
+
|
|
107
|
+
die(e)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def emit(file, options)
|
|
111
|
+
result = run(file, options)
|
|
112
|
+
unless result.success?
|
|
113
|
+
raise result.error if raw_error?(options)
|
|
114
|
+
|
|
115
|
+
die(result.error)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
result.value.Out()
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def help
|
|
122
|
+
<<~HELP
|
|
123
|
+
Usage: #{PROGNAME} [options...] [--] [SVG file|-]
|
|
124
|
+
|
|
125
|
+
See documentation for detailed help.
|
|
126
|
+
|
|
127
|
+
Options:
|
|
128
|
+
|
|
129
|
+
--omit ATTRIBUTE Omit an attribute (repeatable)
|
|
130
|
+
-r, --require LIB Require Ruby LIB while evaluating generated source
|
|
131
|
+
-x, --exception Raise exception instead of abort
|
|
132
|
+
-- Stop option parsing
|
|
133
|
+
|
|
134
|
+
-h, --help Show this help
|
|
135
|
+
-v, --version Display version
|
|
136
|
+
HELP
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def operand(argv)
|
|
140
|
+
file = argv.shift
|
|
141
|
+
Error.("Unexpected argument: #{argv.first}") unless argv.empty?
|
|
142
|
+
|
|
143
|
+
file unless file == "-"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def raw_error?(options) = options&.vomit || ENV.fetch(ENVVOMIT, nil)
|
|
147
|
+
|
|
148
|
+
def run(file, options)
|
|
149
|
+
source = if file
|
|
150
|
+
::Sevgi::Derender.derender_file(file, omit: options.omit)
|
|
151
|
+
else
|
|
152
|
+
::Sevgi::Derender.derender($stdin.read, omit: options.omit)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
::Sevgi.execute(source, require: options.require)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private_constant :Binaries
|
|
161
|
+
end
|
data/lib/sevgi/binaries/rake.rb
CHANGED
|
@@ -14,11 +14,11 @@ module FileUtils
|
|
|
14
14
|
# @param file [String] Sevgi script file, with or without `.sevgi` extension
|
|
15
15
|
# @param args [Array] positional arguments exposed to the script as `ARGA`
|
|
16
16
|
# @param kwargs [Hash] keyword arguments exposed to the script as `ARGH`
|
|
17
|
-
# @return [Sevgi::Executor::
|
|
17
|
+
# @return [Sevgi::Executor::Result] immutable execution result
|
|
18
18
|
# @raise [Sevgi::ArgumentError] when the script file cannot be found
|
|
19
|
-
# @see Sevgi
|
|
19
|
+
# @see Sevgi.execute_file
|
|
20
20
|
def sevgi(file, *args, **kwargs)
|
|
21
|
-
Sevgi::Executor.execute_file
|
|
21
|
+
Sevgi::Executor.__send__(:execute_file, Sevgi::F.existing!(file, [Sevgi::EXTENSION])) do
|
|
22
22
|
extend(Sevgi)
|
|
23
23
|
|
|
24
24
|
const_set(:ARGA, args).freeze
|
data/lib/sevgi/binaries/sevgi.rb
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "sevgi"
|
|
4
|
+
require "sevgi/skill"
|
|
4
5
|
|
|
5
6
|
module Sevgi
|
|
6
7
|
# Command-line entrypoint implementations shipped with Sevgi.
|
|
8
|
+
# @api private
|
|
7
9
|
module Binaries
|
|
8
10
|
# Implements the `sevgi` executable.
|
|
11
|
+
# @api private
|
|
9
12
|
module Sevgi
|
|
10
13
|
extend self
|
|
11
14
|
|
|
12
15
|
# Executable name used in help output.
|
|
13
16
|
PROGNAME = "sevgi"
|
|
14
17
|
|
|
18
|
+
# Logical source name used for standard input.
|
|
19
|
+
STDIN_NAME = "output.sevgi"
|
|
20
|
+
|
|
15
21
|
# Error raised for invalid command-line usage.
|
|
16
22
|
Error = Class.new(::Sevgi::Error)
|
|
17
23
|
|
|
24
|
+
FLAGS = {
|
|
25
|
+
"-x" => :vomit,
|
|
26
|
+
"--exception" => :vomit,
|
|
27
|
+
"-h" => :help,
|
|
28
|
+
"--help" => :help,
|
|
29
|
+
"--skill" => :skill,
|
|
30
|
+
"-v" => :version,
|
|
31
|
+
"--version" => :version
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
18
34
|
# Parsed command-line options for the `sevgi` executable.
|
|
19
35
|
# @api private
|
|
20
|
-
Options = Struct.new(:require, :
|
|
36
|
+
Options = Struct.new(:require, :vomit, :help, :skill, :version, :as) do
|
|
21
37
|
# Parses command-line options and removes them from the argv array.
|
|
22
38
|
# @param argv [Array<String>] mutable command-line argument array
|
|
23
39
|
# @return [Sevgi::Binaries::Sevgi::Options] parsed options
|
|
24
|
-
# @raise [Sevgi::Binaries::Sevgi::Error] when an option is not recognized
|
|
40
|
+
# @raise [Sevgi::Binaries::Sevgi::Error] when an option is not recognized or a required value is missing
|
|
25
41
|
def self.parse(argv)
|
|
26
42
|
new.tap do |options|
|
|
27
|
-
argv.
|
|
43
|
+
until argv.empty? || argv.first == "-" || !argv.first.start_with?("-")
|
|
44
|
+
if argv.first == "--"
|
|
45
|
+
argv.shift
|
|
46
|
+
break
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
option(argv, options)
|
|
50
|
+
end
|
|
28
51
|
end
|
|
29
52
|
end
|
|
30
53
|
|
|
@@ -32,18 +55,14 @@ module Sevgi
|
|
|
32
55
|
private
|
|
33
56
|
|
|
34
57
|
def option(argv, options)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
options
|
|
38
|
-
|
|
39
|
-
options.
|
|
40
|
-
|
|
41
|
-
options.
|
|
42
|
-
|
|
43
|
-
when "-h", "--help"
|
|
44
|
-
options.help = true
|
|
45
|
-
when "-v", "--version"
|
|
46
|
-
options.version = true
|
|
58
|
+
arg = argv.shift
|
|
59
|
+
if (flag = FLAGS[arg])
|
|
60
|
+
options[flag] = true
|
|
61
|
+
elsif ["-r", "--require"].include?(arg)
|
|
62
|
+
options.require = argv.shift || Error.("Option requires a library: #{arg}")
|
|
63
|
+
elsif arg == "--as"
|
|
64
|
+
options.as = argv.shift
|
|
65
|
+
Error.("Option requires a name: --as") if options.as.nil? || options.as.empty?
|
|
47
66
|
else
|
|
48
67
|
Error.("Not a valid option: #{arg}")
|
|
49
68
|
end
|
|
@@ -51,61 +70,87 @@ module Sevgi
|
|
|
51
70
|
end
|
|
52
71
|
end
|
|
53
72
|
|
|
54
|
-
private_constant :Options
|
|
73
|
+
private_constant :FLAGS, :Options, :STDIN_NAME
|
|
55
74
|
|
|
56
75
|
# Runs the `sevgi` command-line interface.
|
|
57
76
|
# @param argv [Array<String>, String, nil] command-line arguments
|
|
58
77
|
# @return [nil]
|
|
59
78
|
# @raise [Sevgi::Executor::Error] when `--exception` or `SEVGI_VOMIT` requests raw executor errors
|
|
60
|
-
# @raise [SystemExit] when
|
|
79
|
+
# @raise [SystemExit] when argv does not match `[options...] [--] [file|-]` or script execution aborts
|
|
61
80
|
def call(argv)
|
|
62
81
|
return puts(help) if (options = Options.parse(argv = Array(argv))).help
|
|
82
|
+
return puts(Skill.path) if options.skill
|
|
63
83
|
return puts(::Sevgi::VERSION) if options.version
|
|
64
84
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if result.error?
|
|
68
|
-
raise result.error if options.vomit || ENV[ENVVOMIT]
|
|
69
|
-
|
|
70
|
-
die(result.error, file)
|
|
71
|
-
end
|
|
85
|
+
file = operand(argv)
|
|
86
|
+
handle(run(file, options), file, options)
|
|
72
87
|
|
|
73
|
-
rescue
|
|
88
|
+
rescue Skill::Error => e
|
|
74
89
|
abort(e.message)
|
|
90
|
+
rescue Binaries::Sevgi::Error => e
|
|
91
|
+
abort("#{e.message}\n\n#{help}")
|
|
75
92
|
end
|
|
76
93
|
|
|
77
94
|
private
|
|
78
95
|
|
|
79
96
|
def die(error, _file)
|
|
80
|
-
warn(error.message)
|
|
81
|
-
warn("")
|
|
82
|
-
error.backtrace!.each { warn(" #{it}") }
|
|
83
|
-
|
|
97
|
+
warn(error.message, "", *error.load_backtrace.map { " #{it}" })
|
|
84
98
|
exit(1)
|
|
85
99
|
end
|
|
86
100
|
|
|
101
|
+
def handle(result, file, options)
|
|
102
|
+
return unless result&.error?
|
|
103
|
+
|
|
104
|
+
raise result.error if options.vomit || ENV[ENVVOMIT]
|
|
105
|
+
|
|
106
|
+
die(result.error, file)
|
|
107
|
+
end
|
|
108
|
+
|
|
87
109
|
def help
|
|
88
110
|
<<~HELP
|
|
89
|
-
Usage: #{PROGNAME} [options...]
|
|
111
|
+
Usage: #{PROGNAME} [options...] [--] [Sevgi file|-]
|
|
90
112
|
|
|
91
113
|
See documentation for detailed help.
|
|
92
114
|
|
|
93
115
|
Options:
|
|
94
116
|
|
|
95
|
-
|
|
117
|
+
--as NAME Evaluate input as NAME for implicit output names
|
|
96
118
|
-r, --require LIB Require Ruby LIB
|
|
119
|
+
--skill Display the packaged agent skill path
|
|
97
120
|
-x, --exception Raise exception instead of abort
|
|
121
|
+
-- Stop option parsing
|
|
98
122
|
|
|
99
123
|
-h, --help Show this help
|
|
100
124
|
-v, --version Display version
|
|
101
125
|
HELP
|
|
102
126
|
end
|
|
103
127
|
|
|
128
|
+
def operand(argv)
|
|
129
|
+
file = argv.shift
|
|
130
|
+
Error.("Unexpected argument: #{argv.first}") unless argv.empty?
|
|
131
|
+
|
|
132
|
+
file unless file == "-"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def execute_file(file, options)
|
|
136
|
+
name = source_name(options.as) if options.as
|
|
137
|
+
::Sevgi.execute_file(file, as: name, require: options.require, main: true)
|
|
138
|
+
end
|
|
139
|
+
|
|
104
140
|
def run(file, options)
|
|
105
|
-
|
|
141
|
+
return execute_file(file, options) if file
|
|
106
142
|
|
|
107
|
-
::Sevgi.
|
|
143
|
+
::Sevgi.execute($stdin.read, file: source_name(options.as), require: options.require, main: true)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def source_name(name)
|
|
147
|
+
return STDIN_NAME unless name
|
|
148
|
+
|
|
149
|
+
Error.("Option requires a name, not a path: --as") unless ::File.basename(name) == name
|
|
150
|
+
F.subext(".sevgi", name)
|
|
108
151
|
end
|
|
109
152
|
end
|
|
110
153
|
end
|
|
154
|
+
|
|
155
|
+
private_constant :Binaries
|
|
111
156
|
end
|
data/lib/sevgi/executor/error.rb
CHANGED
|
@@ -2,43 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
class Executor
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# @!attribute [r] scope
|
|
10
|
-
# @return [Sevgi::Executor::Scope] executor scope active when the error was captured
|
|
11
|
-
attr_reader :error, :scope
|
|
5
|
+
# Raised when a source attempts to load another source already active in the same scope.
|
|
6
|
+
# @see https://sevgi.roktas.dev/execution/ Execution guide
|
|
7
|
+
class CycleError < ::Sevgi::Error
|
|
8
|
+
end
|
|
12
9
|
|
|
10
|
+
# Wraps an exception raised while executing Sevgi script source. Its visited source snapshot records every source in
|
|
11
|
+
# load order; it is not the active load stack at the instant of failure.
|
|
12
|
+
# @see https://sevgi.roktas.dev/execution/ Execution guide
|
|
13
|
+
class Error < ::Sevgi::Error
|
|
13
14
|
# Builds an executor error wrapper.
|
|
14
15
|
# @param error [Exception] original exception
|
|
15
|
-
# @param
|
|
16
|
+
# @param stack [Array<String>] source file keys visited in load order; the Array and its String entries are copied
|
|
17
|
+
# and frozen
|
|
16
18
|
# @return [void]
|
|
17
|
-
def initialize(error,
|
|
18
|
-
@
|
|
19
|
-
@
|
|
19
|
+
def initialize(error, stack)
|
|
20
|
+
@cause = error
|
|
21
|
+
@stack = stack.map { it.dup.freeze }.freeze
|
|
20
22
|
|
|
21
23
|
super(error.message)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
# Returns backtrace entries that belong to the Sevgi
|
|
25
|
-
# @return [Array<String>] filtered backtrace lines relative to the current directory
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# Returns backtrace entries that belong to the visited Sevgi source set.
|
|
27
|
+
# @return [Array<String>] filtered backtrace lines relative to the current directory, or an empty Array when the
|
|
28
|
+
# original exception has no backtrace
|
|
29
|
+
def load_backtrace
|
|
30
|
+
sources = @stack.map { ::File.expand_path(it) }
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
.backtrace
|
|
32
|
+
Array(cause.backtrace)
|
|
31
33
|
.select { sources.include?(::File.expand_path(it.split(":", 2).first)) }
|
|
32
34
|
.map { |line| line.delete_prefix("#{::Dir.pwd}/") }
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# Returns the original exception as the wrapped cause.
|
|
36
38
|
# @return [Exception]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# Returns the script load stack active at failure time.
|
|
40
|
-
# @return [Array<String>] script file names in load order
|
|
41
|
-
def stack = scope.stack
|
|
39
|
+
attr_reader :cause
|
|
42
40
|
end
|
|
43
41
|
end
|
|
44
42
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
class Executor
|
|
5
|
+
# Describes the outcome of one executor invocation.
|
|
6
|
+
#
|
|
7
|
+
# A successful result has a value and no error. A captured script, file, or
|
|
8
|
+
# library failure has an {Executor::Error} and may retain a value produced
|
|
9
|
+
# before the failure. The source stack is an immutable snapshot in load
|
|
10
|
+
# order; it is never shared with the executor's internal mutable state.
|
|
11
|
+
#
|
|
12
|
+
# @example Inspect successful execution
|
|
13
|
+
# result = Sevgi.execute("6 * 7")
|
|
14
|
+
# result.success? #=> true
|
|
15
|
+
# result.value #=> 42
|
|
16
|
+
#
|
|
17
|
+
# @example Inspect a captured failure
|
|
18
|
+
# result = Sevgi.execute("missing", file: "drawing.sevgi")
|
|
19
|
+
# result.error? #=> true
|
|
20
|
+
# result.error.cause #=> #<NameError ...>
|
|
21
|
+
# result.stack #=> ["drawing.sevgi"]
|
|
22
|
+
#
|
|
23
|
+
# @see Sevgi.execute
|
|
24
|
+
# @see Sevgi.execute_file
|
|
25
|
+
# @see https://sevgi.roktas.dev/execution/ Execution guide
|
|
26
|
+
Result = Data.define(:value, :error, :stack) do
|
|
27
|
+
# @!attribute [r] value
|
|
28
|
+
# @return [Object, nil] last value produced, or nil when no value was produced
|
|
29
|
+
# @!attribute [r] error
|
|
30
|
+
# @return [Sevgi::Executor::Error, nil] captured failure, or nil after successful execution
|
|
31
|
+
# @!attribute [r] stack
|
|
32
|
+
# @return [Array<String>] frozen owned source-path snapshot in load order
|
|
33
|
+
|
|
34
|
+
# Creates an execution result.
|
|
35
|
+
# @param value [Object, nil] last value produced before execution finished
|
|
36
|
+
# @param error [Sevgi::Executor::Error, nil] captured execution failure
|
|
37
|
+
# @param stack [Array<String>] source files visited in load order
|
|
38
|
+
# @return [void]
|
|
39
|
+
def initialize(value:, error:, stack:)
|
|
40
|
+
super(value:, error:, stack: Array(stack).map { it.dup.freeze }.freeze)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :[]
|
|
44
|
+
|
|
45
|
+
# Reports whether execution completed without a captured error.
|
|
46
|
+
# @return [Boolean] true when execution succeeded
|
|
47
|
+
def success? = error.nil?
|
|
48
|
+
|
|
49
|
+
# Reports whether execution captured an error.
|
|
50
|
+
# @return [Boolean] true when execution failed
|
|
51
|
+
def error? = !success?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/sevgi/executor/scope.rb
CHANGED
|
@@ -6,13 +6,14 @@ module Sevgi
|
|
|
6
6
|
#
|
|
7
7
|
# Scope objects belong to one executor run and are pushed onto the current
|
|
8
8
|
# fiber's executor stack. They are not shared between concurrent executions.
|
|
9
|
+
# @api private
|
|
9
10
|
class Scope
|
|
10
11
|
# @!attribute [r] scope
|
|
11
12
|
# @return [Module] isolated module where script source is evaluated
|
|
12
13
|
# @!attribute [r] recent
|
|
13
14
|
# @return [Object, nil] last expression result from the executed source
|
|
14
15
|
# @!attribute [r] error
|
|
15
|
-
# @return [
|
|
16
|
+
# @return [Exception, nil] captured execution error
|
|
16
17
|
attr_reader :scope, :recent, :error
|
|
17
18
|
|
|
18
19
|
# Creates a script execution scope.
|
|
@@ -25,6 +26,7 @@ module Sevgi
|
|
|
25
26
|
@recent = nil
|
|
26
27
|
@error = nil
|
|
27
28
|
@stack = {}
|
|
29
|
+
@active = {}
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# Reports whether execution captured an error.
|
|
@@ -33,24 +35,16 @@ module Sevgi
|
|
|
33
35
|
|
|
34
36
|
# Executes one source object in this scope.
|
|
35
37
|
# @param source [Sevgi::Executor::Source] source to evaluate
|
|
36
|
-
# @param receiver [Object, nil] receiver
|
|
38
|
+
# @param receiver [Object, nil] explicit boot receiver, or nil to use the isolated scope module
|
|
37
39
|
# @yield optional boot block that installs DSL methods before evaluation
|
|
38
40
|
# @yieldreturn [void]
|
|
39
41
|
# @return [Sevgi::Executor::Scope] self, with recent or error populated
|
|
40
42
|
# @api private
|
|
41
43
|
def call(source, receiver = nil, &boot)
|
|
42
44
|
push(source)
|
|
45
|
+
execute(source, receiver, &boot)
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
@recent = run(source, receiver, &boot)
|
|
46
|
-
|
|
47
|
-
# rubocop:disable Lint/RescueException
|
|
48
|
-
rescue Exception => e
|
|
49
|
-
@error = Executor::Error.new(e, self)
|
|
50
|
-
|
|
51
|
-
throw(:result, self)
|
|
52
|
-
# rubocop:enable Lint/RescueException
|
|
53
|
-
end
|
|
47
|
+
self
|
|
54
48
|
end
|
|
55
49
|
|
|
56
50
|
# Captures a preprocessing failure for this scope.
|
|
@@ -60,7 +54,7 @@ module Sevgi
|
|
|
60
54
|
# @api private
|
|
61
55
|
def capture(source, error)
|
|
62
56
|
push(source)
|
|
63
|
-
@error =
|
|
57
|
+
@error = error
|
|
64
58
|
self
|
|
65
59
|
end
|
|
66
60
|
|
|
@@ -83,6 +77,16 @@ module Sevgi
|
|
|
83
77
|
# @note The stack is owned by this scope and is not shared with concurrent executions.
|
|
84
78
|
def stack = @stack.keys
|
|
85
79
|
|
|
80
|
+
# Builds the immutable public result for this scope.
|
|
81
|
+
# @return [Sevgi::Executor::Result] execution result snapshot
|
|
82
|
+
# @api private
|
|
83
|
+
def result
|
|
84
|
+
sources = stack.freeze
|
|
85
|
+
error = Executor::Error.new(@error, sources) if @error
|
|
86
|
+
|
|
87
|
+
Result.new(value: recent, error:, stack: sources)
|
|
88
|
+
end
|
|
89
|
+
|
|
86
90
|
# Returns the most recently pushed source.
|
|
87
91
|
# @return [Sevgi::Executor::Source, nil] most recent source object
|
|
88
92
|
# @api private
|
|
@@ -93,7 +97,8 @@ module Sevgi
|
|
|
93
97
|
def boot(receiver, &boot)
|
|
94
98
|
return unless boot
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
receiver = scope if receiver.nil?
|
|
101
|
+
receiver.public_send(receiver.is_a?(::Module) ? :module_exec : :instance_exec, &boot)
|
|
97
102
|
end
|
|
98
103
|
|
|
99
104
|
def evaluate(source)
|
|
@@ -109,6 +114,33 @@ module Sevgi
|
|
|
109
114
|
tap { @stack[source.key] = source }
|
|
110
115
|
end
|
|
111
116
|
|
|
117
|
+
def enter(source)
|
|
118
|
+
if @active.key?(source.identity)
|
|
119
|
+
raise Executor::CycleError, "Recursive Sevgi load: #{source.file}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@active[source.identity] = source
|
|
123
|
+
source
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def leave(source)
|
|
127
|
+
return unless source
|
|
128
|
+
|
|
129
|
+
@active.delete(source.identity) if @active[source.identity].equal?(source)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def execute(source, receiver, &boot)
|
|
133
|
+
active = enter(source)
|
|
134
|
+
@recent = run(source, receiver, &boot)
|
|
135
|
+
# rubocop:disable Lint/RescueException
|
|
136
|
+
rescue Exception => e
|
|
137
|
+
@error = e
|
|
138
|
+
throw(:result, self)
|
|
139
|
+
# rubocop:enable Lint/RescueException
|
|
140
|
+
ensure
|
|
141
|
+
leave(active)
|
|
142
|
+
end
|
|
143
|
+
|
|
112
144
|
def run(source, receiver, &boot)
|
|
113
145
|
boot(receiver, &boot)
|
|
114
146
|
evaluate(source)
|