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.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sevgi
2
2
 
3
- Top-level API and `.sevgi` script runner.
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(:minimal) do
21
- rect(width: 3, height: 5)
20
+ SVG :minimal do
21
+ rect width: 3, height: 5
22
22
  end.call
23
23
  ```
24
24
 
25
- ## Executable
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 current development Ruby from `.ruby-version`.
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sevgi/binaries/igsev"
5
+
6
+ Sevgi.const_get(:Binaries, false)::Igsev.(ARGV)
data/bin/sevgi CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "sevgi/binaries/sevgi"
5
5
 
6
- Sevgi::Binaries::Sevgi.(ARGV)
6
+ Sevgi.const_get(:Binaries, false)::Sevgi.(ARGV)
@@ -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
@@ -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::Scope, nil] execution scope, or nil for an empty file
17
+ # @return [Sevgi::Executor::Result] immutable execution result
18
18
  # @raise [Sevgi::ArgumentError] when the script file cannot be found
19
- # @see Sevgi::Executor.execute_file
19
+ # @see Sevgi.execute_file
20
20
  def sevgi(file, *args, **kwargs)
21
- Sevgi::Executor.execute_file(Sevgi::F.existing!(file, [Sevgi::EXTENSION])) do
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
@@ -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, :nomain, :vomit, :help, :version) do
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.first.start_with?("-") ? option(argv, options) : break until argv.empty?
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
- case (arg = argv.shift)
36
- when "-r", "--require"
37
- options.require = argv.shift
38
- when "-n", "--nomain"
39
- options.nomain = true
40
- when "-x", "--exception"
41
- options.vomit = true
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 command-line usage or script execution aborts
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
- result = run(file = argv.shift, options)
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 Binaries::Sevgi::Error => e
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...] <Sevgi file>
111
+ Usage: #{PROGNAME} [options...] [--] [Sevgi file|-]
90
112
 
91
113
  See documentation for detailed help.
92
114
 
93
115
  Options:
94
116
 
95
- -n, --nomain Do not modify main object
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
- Error.("No sevgi file given.") unless file
141
+ return execute_file(file, options) if file
106
142
 
107
- ::Sevgi.execute_file(file, require: options.require, receiver: options.nomain ? nil : TOPLEVEL_BINDING.receiver)
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
@@ -2,43 +2,41 @@
2
2
 
3
3
  module Sevgi
4
4
  class Executor
5
- # Wraps an exception raised while executing Sevgi script source.
6
- class Error < ::Sevgi::Error
7
- # @!attribute [r] error
8
- # @return [Exception] original exception raised by script execution
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 scope [Sevgi::Executor::Scope] executor scope active at failure time
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, scope)
18
- @error = error
19
- @scope = scope
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 load stack.
25
- # @return [Array<String>] filtered backtrace lines relative to the current directory
26
- def backtrace!
27
- sources = stack.map { ::File.expand_path(it) }
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
- error
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
- def cause = error
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
@@ -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 [Sevgi::Executor::Error, nil] captured execution error
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 used while booting the DSL
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
- tap do
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 = Executor::Error.new(error, self)
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
- (receiver ||= scope).public_send(receiver.is_a?(::Module) ? :module_exec : :instance_exec, &boot)
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)