sevgi 0.95.0 → 1.0.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.
@@ -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,64 +70,88 @@ 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
- file = argv.shift
66
- handle(run(file, options), file, options)
85
+ file = operand(argv)
86
+ handle(run(file, options), options)
67
87
 
68
- rescue Binaries::Sevgi::Error => e
88
+ rescue Skill::Error => e
69
89
  abort(e.message)
90
+ rescue Binaries::Sevgi::Error => e
91
+ abort("#{e.message}\n\n#{help}")
70
92
  end
71
93
 
72
94
  private
73
95
 
74
- def die(error, _file)
75
- warn(error.message)
76
- warn("")
77
- error.backtrace!.each { warn(" #{it}") }
78
-
96
+ def die(error)
97
+ message = error.message.empty? ? error.cause.class.to_s : error.message
98
+ warn(message, "", *error.load_backtrace.map { " #{it}" })
79
99
  exit(1)
80
100
  end
81
101
 
82
- def handle(result, file, options)
102
+ def handle(result, options)
83
103
  return unless result&.error?
84
104
 
85
105
  raise result.error if options.vomit || ENV[ENVVOMIT]
86
106
 
87
- die(result.error, file)
107
+ die(result.error)
88
108
  end
89
109
 
90
110
  def help
91
111
  <<~HELP
92
- Usage: #{PROGNAME} [options...] <Sevgi file>
112
+ Usage: #{PROGNAME} [options...] [--] [Sevgi file|-]
93
113
 
94
114
  See documentation for detailed help.
95
115
 
96
116
  Options:
97
117
 
98
- -n, --nomain Do not modify main object
118
+ --as NAME Evaluate input as NAME for implicit output names
99
119
  -r, --require LIB Require Ruby LIB
120
+ --skill Display the packaged agent skill path
100
121
  -x, --exception Raise exception instead of abort
122
+ -- Stop option parsing
101
123
 
102
124
  -h, --help Show this help
103
125
  -v, --version Display version
104
126
  HELP
105
127
  end
106
128
 
129
+ def operand(argv)
130
+ file = argv.shift
131
+ Error.("Unexpected argument: #{argv.first}") unless argv.empty?
132
+
133
+ file unless file == "-"
134
+ end
135
+
136
+ def execute_file(file, options)
137
+ name = source_name(options.as) if options.as
138
+ ::Sevgi.execute_file(file, as: name, require: options.require, main: true)
139
+ end
140
+
107
141
  def run(file, options)
108
- Error.("No sevgi file given.") unless file
142
+ return execute_file(file, options) if file
109
143
 
110
- ::Sevgi.execute_file(file, require: options.require, receiver: options.nomain ? nil : TOPLEVEL_BINDING.receiver)
144
+ ::Sevgi.execute($stdin.read, file: source_name(options.as), require: options.require, main: true)
145
+ end
146
+
147
+ def source_name(name)
148
+ return STDIN_NAME unless name
149
+
150
+ Error.("Option requires a name, not a path: --as") unless ::File.basename(name) == name
151
+ F.subext(".sevgi", name)
111
152
  end
112
153
  end
113
154
  end
155
+
156
+ private_constant :Binaries
114
157
  end
@@ -3,48 +3,48 @@
3
3
  module Sevgi
4
4
  class Executor
5
5
  # Raised when a source attempts to load another source already active in the same scope.
6
+ # @see https://sevgi.roktas.dev/usage/#execute Execute source guide
6
7
  class CycleError < ::Sevgi::Error
7
8
  end
8
9
 
9
- # Wraps an exception raised while executing Sevgi script source.
10
- class Error < ::Sevgi::Error
11
- # Returns the original script exception.
12
- # @return [Exception]
13
- attr_reader :error
14
-
15
- # Returns the executor scope active when the error was captured.
16
- # @return [Sevgi::Executor::Scope]
17
- attr_reader :scope
10
+ # Raised when nested Sevgi loads exceed the executor's supported active-source depth.
11
+ #
12
+ # The limit is 128 active sources, including the entry source. It does not count Ruby stack frames
13
+ # or guarantee protection from stack exhaustion within scripts.
14
+ # @see https://sevgi.roktas.dev/usage/#execute Execute source guide
15
+ class LoadDepthError < ::Sevgi::Error
16
+ end
18
17
 
18
+ # Wraps an exception raised while executing Sevgi script source. Its visited source snapshot records every source in
19
+ # load order. It is not the active load stack at the instant of failure.
20
+ # @see https://sevgi.roktas.dev/usage/#execute Execute source guide
21
+ class Error < ::Sevgi::Error
19
22
  # Builds an executor error wrapper.
20
23
  # @param error [Exception] original exception
21
- # @param scope [Sevgi::Executor::Scope] executor scope active at failure time
24
+ # @param stack [Array<String>] source file keys visited in load order. The Array and its String entries are copied
25
+ # and frozen
22
26
  # @return [void]
23
- def initialize(error, scope)
24
- @error = error
25
- @scope = scope
27
+ def initialize(error, stack)
28
+ @cause = error
29
+ @stack = stack.map { it.dup.freeze }.freeze
26
30
 
27
31
  super(error.message)
28
32
  end
29
33
 
30
- # Returns backtrace entries that belong to the Sevgi load stack.
31
- # @return [Array<String>] filtered backtrace lines relative to the current directory
32
- def backtrace!
33
- sources = stack.map { ::File.expand_path(it) }
34
+ # Returns backtrace entries that belong to the visited Sevgi source set.
35
+ # @return [Array<String>] filtered backtrace lines relative to the current directory, or an empty Array when the
36
+ # original exception has no backtrace
37
+ def load_backtrace
38
+ sources = @stack.map { ::File.expand_path(it) }
34
39
 
35
- error
36
- .backtrace
40
+ Array(cause.backtrace)
37
41
  .select { sources.include?(::File.expand_path(it.split(":", 2).first)) }
38
42
  .map { |line| line.delete_prefix("#{::Dir.pwd}/") }
39
43
  end
40
44
 
41
45
  # Returns the original exception as the wrapped cause.
42
46
  # @return [Exception]
43
- def cause = error
44
-
45
- # Returns the script load stack active at failure time.
46
- # @return [Array<String>] script file names in load order
47
- def stack = scope.stack
47
+ attr_reader :cause
48
48
  end
49
49
  end
50
50
  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 can 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/usage/#execute Execute source 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
@@ -8,12 +8,15 @@ module Sevgi
8
8
  # fiber's executor stack. They are not shared between concurrent executions.
9
9
  # @api private
10
10
  class Scope
11
+ MAX_LOAD_DEPTH = 128
12
+ private_constant :MAX_LOAD_DEPTH
13
+
11
14
  # @!attribute [r] scope
12
15
  # @return [Module] isolated module where script source is evaluated
13
16
  # @!attribute [r] recent
14
17
  # @return [Object, nil] last expression result from the executed source
15
18
  # @!attribute [r] error
16
- # @return [Sevgi::Executor::Error, nil] captured execution error
19
+ # @return [Exception, nil] captured execution error
17
20
  attr_reader :scope, :recent, :error
18
21
 
19
22
  # Creates a script execution scope.
@@ -35,7 +38,7 @@ module Sevgi
35
38
 
36
39
  # Executes one source object in this scope.
37
40
  # @param source [Sevgi::Executor::Source] source to evaluate
38
- # @param receiver [Object, nil] receiver used while booting the DSL
41
+ # @param receiver [Object, nil] explicit boot receiver, or nil to use the isolated scope module
39
42
  # @yield optional boot block that installs DSL methods before evaluation
40
43
  # @yieldreturn [void]
41
44
  # @return [Sevgi::Executor::Scope] self, with recent or error populated
@@ -54,7 +57,7 @@ module Sevgi
54
57
  # @api private
55
58
  def capture(source, error)
56
59
  push(source)
57
- @error = Executor::Error.new(error, self)
60
+ @error = error
58
61
  self
59
62
  end
60
63
 
@@ -77,6 +80,16 @@ module Sevgi
77
80
  # @note The stack is owned by this scope and is not shared with concurrent executions.
78
81
  def stack = @stack.keys
79
82
 
83
+ # Builds the immutable public result for this scope.
84
+ # @return [Sevgi::Executor::Result] execution result snapshot
85
+ # @api private
86
+ def result
87
+ sources = stack.freeze
88
+ error = Executor::Error.new(@error, sources) if @error
89
+
90
+ Result.new(value: recent, error:, stack: sources)
91
+ end
92
+
80
93
  # Returns the most recently pushed source.
81
94
  # @return [Sevgi::Executor::Source, nil] most recent source object
82
95
  # @api private
@@ -87,7 +100,8 @@ module Sevgi
87
100
  def boot(receiver, &boot)
88
101
  return unless boot
89
102
 
90
- (receiver ||= scope).public_send(receiver.is_a?(::Module) ? :module_exec : :instance_exec, &boot)
103
+ receiver = scope if receiver.nil?
104
+ receiver.public_send(receiver.is_a?(::Module) ? :module_exec : :instance_exec, &boot)
91
105
  end
92
106
 
93
107
  def evaluate(source)
@@ -104,8 +118,13 @@ module Sevgi
104
118
  end
105
119
 
106
120
  def enter(source)
107
- if @active.key?(source.identity)
108
- raise Executor::CycleError, "Recursive Sevgi load: #{source.file}"
121
+ raise Executor::CycleError, "Recursive Sevgi load: #{source.file}" if @active.key?(source.identity)
122
+
123
+ if @active.size >= MAX_LOAD_DEPTH
124
+ raise(
125
+ Executor::LoadDepthError,
126
+ "Sevgi load nesting too deep (maximum #{MAX_LOAD_DEPTH} active sources): #{source.file}"
127
+ )
109
128
  end
110
129
 
111
130
  @active[source.identity] = source
@@ -121,11 +140,10 @@ module Sevgi
121
140
  def execute(source, receiver, &boot)
122
141
  active = enter(source)
123
142
  @recent = run(source, receiver, &boot)
124
- # rubocop:disable Lint/RescueException
143
+ # rubocop:disable-next Lint/RescueException
125
144
  rescue Exception => e
126
- @error = Executor::Error.new(e, self)
145
+ @error = e
127
146
  throw(:result, self)
128
- # rubocop:enable Lint/RescueException
129
147
  ensure
130
148
  leave(active)
131
149
  end
@@ -4,38 +4,47 @@ module Sevgi
4
4
  class Executor
5
5
  # Describes Ruby source evaluated by the Sevgi executor.
6
6
  # @api private
7
- Source = Data.define(:string, :file, :line) do
8
- # @overload call(string:, file: nil, line: nil)
7
+ Source = Data.define(:string, :file, :line, :origin) do
8
+ # @overload call(string:, file: nil, line: nil, origin: nil)
9
9
  # Builds a source object.
10
10
  # @param string [String] Ruby source string
11
11
  # @param file [String, nil] source file name for diagnostics
12
12
  # @param line [Integer, nil] starting source line for diagnostics
13
+ # @param origin [String, nil] physical source path used for load-cycle identity
13
14
  # @return [Sevgi::Executor::Source] source object
14
15
  def self.call(...) = new(...)
15
16
 
16
17
  # Builds a source object from a file.
17
18
  # @param file [String] source file to read
19
+ # @param as [String, nil] logical source name used for evaluation and diagnostics
18
20
  # @return [Sevgi::Executor::Source] source object with file contents
19
21
  # @raise [Errno::ENOENT] when the file cannot be read
20
- def self.load(file) = new(string: ::File.read(file), file: file, line: 1)
22
+ def self.load(file, as: nil) = new(string: ::File.read(file), file: as || file, line: 1, origin: file)
21
23
 
22
24
  # Creates a source object.
23
25
  # @param string [String] Ruby source string
24
26
  # @param file [String, nil] source file name for diagnostics
25
27
  # @param line [Integer, nil] starting source line for diagnostics
28
+ # @param origin [String, nil] physical source path used for load-cycle identity
26
29
  # @return [void]
27
- def initialize(string:, file: nil, line: nil) = super(string:, file: file || "sevgi", line: line || 1)
30
+ def initialize(string:, file: nil, line: nil, origin: nil)
31
+ super(string:, file: file || "sevgi", line: line || 1, origin:)
32
+ end
33
+
34
+ private_class_method :[]
28
35
 
29
36
  # Returns the stack key used for this source.
30
37
  # @return [String] source file name
31
38
  def key = file
32
39
 
33
40
  # Returns the canonical identity used for active-load cycle detection.
34
- # @return [String] canonical source identity
41
+ # @return [Integer, String] object identity for inline source, or canonical physical identity for loaded source
35
42
  def identity
36
- ::File.realpath(file)
43
+ return object_id unless origin
44
+
45
+ ::File.realpath(origin)
37
46
  rescue ::SystemCallError
38
- ::File.expand_path(file)
47
+ ::File.expand_path(origin)
39
48
  end
40
49
  end
41
50
  end