sevgi 0.93.1 → 0.95.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.
data/README.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Sevgi
2
2
 
3
- Provides the top-level API and `.sevgi` script runner.
3
+ Top-level API and `.sevgi` script runner.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ SVG(:minimal) do
21
+ rect(width: 3, height: 5)
22
+ end.call
23
+ ```
24
+
25
+ ## Executable
26
+
27
+ ```sh
28
+ sevgi drawing.sevgi
29
+ ```
30
+
31
+ ## Ruby compatibility
32
+
33
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4.0 and the current development Ruby from `.ruby-version`.
34
+
35
+ ## Native prerequisites
36
+
37
+ The top-level gem installs Sevgi's standard components without native export gems. SVG-only scripts need no native
38
+ packages beyond the standard Ruby dependencies.
39
+
40
+ PDF/PNG export helpers come from `sevgi-sundries` and lazily load the optional Ruby gems `cairo`, `rsvg2`, and
41
+ `hexapdf`. On Debian/Ubuntu:
42
+
43
+ ```sh
44
+ sudo apt-get update
45
+ sudo apt-get install -y libcairo2-dev libgdk-pixbuf-2.0-dev libgirepository1.0-dev libglib2.0-dev librsvg2-dev pkg-config
46
+ gem install cairo rsvg2 hexapdf
47
+ ```
48
+
49
+ On macOS with Homebrew:
50
+
51
+ ```sh
52
+ brew install cairo gdk-pixbuf gobject-introspection librsvg pkg-config
53
+ gem install cairo rsvg2 hexapdf
54
+ ```
55
+
56
+ ## Links
57
+
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
@@ -5,17 +5,24 @@ require "sevgi"
5
5
  # Extends FileUtils with Sevgi script helpers for Rake tasks.
6
6
  module FileUtils
7
7
  # Thin DSL wrapper to call a script without spawning a shell.
8
+ #
9
+ # The script receives positional arguments through `ARGA` and keyword arguments through `ARGH`.
10
+ #
11
+ # @example Run a Sevgi script from a Rake task
12
+ # sevgi "drawings/card", "front", theme: :dark
13
+ #
8
14
  # @param file [String] Sevgi script file, with or without `.sevgi` extension
9
15
  # @param args [Array] positional arguments exposed to the script as `ARGA`
10
16
  # @param kwargs [Hash] keyword arguments exposed to the script as `ARGH`
11
17
  # @return [Sevgi::Executor::Scope, nil] execution scope, or nil for an empty file
12
18
  # @raise [Sevgi::ArgumentError] when the script file cannot be found
19
+ # @see Sevgi::Executor.execute_file
13
20
  def sevgi(file, *args, **kwargs)
14
- Sevgi.execute_file(F.existing!(file, [EXTENSION])) do |mod|
15
- include(Sevgi)
21
+ Sevgi::Executor.execute_file(Sevgi::F.existing!(file, [Sevgi::EXTENSION])) do
22
+ extend(Sevgi)
16
23
 
17
- mod.const_set(:ARGA, args).freeze
18
- mod.const_set(:ARGH, kwargs).freeze
24
+ const_set(:ARGA, args).freeze
25
+ const_set(:ARGH, kwargs).freeze
19
26
  end
20
27
  end
21
28
  end
@@ -56,20 +56,14 @@ module Sevgi
56
56
  # Runs the `sevgi` command-line interface.
57
57
  # @param argv [Array<String>, String, nil] command-line arguments
58
58
  # @return [nil]
59
- # @raise [LoadError] when a required Ruby library cannot be loaded
60
- # @raise [Sevgi::Executor::Error] when `--exception` or `SEVGI_VOMIT` requests raw errors
59
+ # @raise [Sevgi::Executor::Error] when `--exception` or `SEVGI_VOMIT` requests raw executor errors
61
60
  # @raise [SystemExit] when command-line usage or script execution aborts
62
61
  def call(argv)
63
62
  return puts(help) if (options = Options.parse(argv = Array(argv))).help
64
63
  return puts(::Sevgi::VERSION) if options.version
65
64
 
66
- result = run(file = argv.shift, options)
67
-
68
- if result.error?
69
- raise result.error if options.vomit || ENV[ENVVOMIT]
70
-
71
- die(result.error, file)
72
- end
65
+ file = argv.shift
66
+ handle(run(file, options), file, options)
73
67
 
74
68
  rescue Binaries::Sevgi::Error => e
75
69
  abort(e.message)
@@ -85,6 +79,14 @@ module Sevgi
85
79
  exit(1)
86
80
  end
87
81
 
82
+ def handle(result, file, options)
83
+ return unless result&.error?
84
+
85
+ raise result.error if options.vomit || ENV[ENVVOMIT]
86
+
87
+ die(result.error, file)
88
+ end
89
+
88
90
  def help
89
91
  <<~HELP
90
92
  Usage: #{PROGNAME} [options...] <Sevgi file>
@@ -2,13 +2,19 @@
2
2
 
3
3
  module Sevgi
4
4
  class Executor
5
+ # Raised when a source attempts to load another source already active in the same scope.
6
+ class CycleError < ::Sevgi::Error
7
+ end
8
+
5
9
  # Wraps an exception raised while executing Sevgi script source.
6
10
  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
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
12
18
 
13
19
  # Builds an executor error wrapper.
14
20
  # @param error [Exception] original exception
@@ -32,6 +38,10 @@ module Sevgi
32
38
  .map { |line| line.delete_prefix("#{::Dir.pwd}/") }
33
39
  end
34
40
 
41
+ # Returns the original exception as the wrapped cause.
42
+ # @return [Exception]
43
+ def cause = error
44
+
35
45
  # Returns the script load stack active at failure time.
36
46
  # @return [Array<String>] script file names in load order
37
47
  def stack = scope.stack
@@ -3,10 +3,11 @@
3
3
  module Sevgi
4
4
  class Executor
5
5
  # Holds one isolated Sevgi script execution scope and its result.
6
+ #
7
+ # Scope objects belong to one executor run and are pushed onto the current
8
+ # fiber's executor stack. They are not shared between concurrent executions.
9
+ # @api private
6
10
  class Scope
7
- # Error raised by low-level executor scope operations.
8
- Error = Class.new(::Sevgi::Error)
9
-
10
11
  # @!attribute [r] scope
11
12
  # @return [Module] isolated module where script source is evaluated
12
13
  # @!attribute [r] recent
@@ -18,11 +19,14 @@ module Sevgi
18
19
  # Creates a script execution scope.
19
20
  # @param scope [Module, nil] existing module to evaluate source in
20
21
  # @return [void]
22
+ # @note When no module is supplied, the internal module reports its name as `Sevgi::Main` for readable Ruby error
23
+ # messages without publishing a process-global `Sevgi::Main` constant.
21
24
  def initialize(scope = nil)
22
25
  @scope = scope || main
23
26
  @recent = nil
24
27
  @error = nil
25
28
  @stack = {}
29
+ @active = {}
26
30
  end
27
31
 
28
32
  # Reports whether execution captured an error.
@@ -38,17 +42,20 @@ module Sevgi
38
42
  # @api private
39
43
  def call(source, receiver = nil, &boot)
40
44
  push(source)
45
+ execute(source, receiver, &boot)
41
46
 
42
- tap do
43
- @recent = run(source, receiver, &boot)
44
-
45
- # rubocop:disable Lint/RescueException
46
- rescue Exception => e
47
- @error = Executor::Error.new(e, self)
47
+ self
48
+ end
48
49
 
49
- throw(:result, self)
50
- # rubocop:enable Lint/RescueException
51
- end
50
+ # Captures a preprocessing failure for this scope.
51
+ # @param source [Sevgi::Executor::Source] source active when preprocessing failed
52
+ # @param error [Exception] original preprocessing exception
53
+ # @return [Sevgi::Executor::Scope] self, with error populated
54
+ # @api private
55
+ def capture(source, error)
56
+ push(source)
57
+ @error = Executor::Error.new(error, self)
58
+ self
52
59
  end
53
60
 
54
61
  # Loads a file into this existing execution scope.
@@ -56,12 +63,18 @@ module Sevgi
56
63
  # @yield optional boot block that installs DSL methods before evaluation
57
64
  # @yieldreturn [void]
58
65
  # @return [Sevgi::Executor::Scope] self, with recent or error populated
59
- # @raise [Errno::ENOENT] when the file cannot be read
66
+ # @note File-read failures are captured as {Sevgi::Executor::Error} on this scope.
60
67
  # @api private
61
- def load(file, &block) = call(Source.load(file), &block)
68
+ def load(file, &block)
69
+ call(Source.load(file), &block)
70
+ rescue ::SystemCallError => e
71
+ capture(Source.new(string: "", file:, line: 1), e)
72
+ throw(:result, self)
73
+ end
62
74
 
63
75
  # Returns the unique source stack for this execution.
64
76
  # @return [Array<String>] source file keys in load order
77
+ # @note The stack is owned by this scope and is not shared with concurrent executions.
65
78
  def stack = @stack.keys
66
79
 
67
80
  # Returns the most recently pushed source.
@@ -69,9 +82,6 @@ module Sevgi
69
82
  # @api private
70
83
  def peek = @stack[@stack.keys.last]
71
84
 
72
- # Constant name used for the temporary script module under {Sevgi}.
73
- MAIN_MODULE = :Main
74
-
75
85
  private
76
86
 
77
87
  def boot(receiver, &boot)
@@ -86,16 +96,40 @@ module Sevgi
86
96
  end
87
97
 
88
98
  def main
89
- Module.new.tap do |mod|
90
- Sevgi.send(:remove_const, MAIN_MODULE) if Sevgi.const_defined?(MAIN_MODULE)
91
- Sevgi.const_set(MAIN_MODULE, mod)
92
- end
99
+ Module.new.tap { |mod| mod.define_singleton_method(:name) { "Sevgi::Main" } }
93
100
  end
94
101
 
95
102
  def push(source)
96
103
  tap { @stack[source.key] = source }
97
104
  end
98
105
 
106
+ def enter(source)
107
+ if @active.key?(source.identity)
108
+ raise Executor::CycleError, "Recursive Sevgi load: #{source.file}"
109
+ end
110
+
111
+ @active[source.identity] = source
112
+ source
113
+ end
114
+
115
+ def leave(source)
116
+ return unless source
117
+
118
+ @active.delete(source.identity) if @active[source.identity].equal?(source)
119
+ end
120
+
121
+ def execute(source, receiver, &boot)
122
+ active = enter(source)
123
+ @recent = run(source, receiver, &boot)
124
+ # rubocop:disable Lint/RescueException
125
+ rescue Exception => e
126
+ @error = Executor::Error.new(e, self)
127
+ throw(:result, self)
128
+ # rubocop:enable Lint/RescueException
129
+ ensure
130
+ leave(active)
131
+ end
132
+
99
133
  def run(source, receiver, &boot)
100
134
  boot(receiver, &boot)
101
135
  evaluate(source)
@@ -29,6 +29,14 @@ module Sevgi
29
29
  # Returns the stack key used for this source.
30
30
  # @return [String] source file name
31
31
  def key = file
32
+
33
+ # Returns the canonical identity used for active-load cycle detection.
34
+ # @return [String] canonical source identity
35
+ def identity
36
+ ::File.realpath(file)
37
+ rescue ::SystemCallError
38
+ ::File.expand_path(file)
39
+ end
32
40
  end
33
41
  end
34
42
  end
@@ -11,14 +11,26 @@ module Sevgi
11
11
  #
12
12
  # The executor is used by script mode and by the `Load` DSL word to preserve a
13
13
  # useful load stack while keeping DSL methods out of the caller's global object
14
- # whenever possible.
14
+ # whenever possible. Active scope stacks are isolated per Ruby fiber, so
15
+ # concurrent executions can perform nested `Load` calls without sharing scope
16
+ # state. The process SIGINT handler is shared by Ruby, so executor runs guard it
17
+ # with a reference-counted critical section and restore the previous handler
18
+ # after the last active execution finishes.
15
19
  class Executor
16
20
  include Singleton
17
21
 
22
+ private_constant :Scope
23
+
24
+ # Thread-current key used for the fiber-local executor scope stack.
25
+ # @api private
26
+ SCOPE_KEY = :sevgi_executor_scopes
27
+ private_constant :SCOPE_KEY, :Source
28
+
18
29
  # Loads a script file inside the current executor scope.
19
30
  # @param file [String] path to a Sevgi script file
20
31
  # @return [Sevgi::Executor::Scope] current execution scope
21
32
  # @raise [Sevgi::PanicError] when there is no active executor scope
33
+ # @note Uses the active executor scope from the current fiber.
22
34
  # @api private
23
35
  def self.load(file, ...)
24
36
  PanicError.("box stack empty; create a box first") unless instance.current
@@ -35,20 +47,11 @@ module Sevgi
35
47
  # @yield optional boot block that installs DSL methods before evaluation
36
48
  # @yieldreturn [void]
37
49
  # @return [Sevgi::Executor::Scope, nil] execution scope, or nil for empty source
38
- # @raise [LoadError] when the optional required library cannot be loaded
50
+ # @note Required-library load failures are captured as {Sevgi::Executor::Error} on the returned scope.
51
+ # @note Reentrant and concurrent calls keep independent scope stacks per fiber. The temporary SIGINT handler remains
52
+ # process-global while any execution is active.
39
53
  def self.execute(string, file: nil, line: nil, require: nil, receiver: nil, &block)
40
- return if string.empty?
41
-
42
- interrupt = Signal.trap("INT") { Kernel.abort("") }
43
-
44
- ::Kernel.require(require) if require
45
-
46
- scope = instance.create
47
- catch(:result) { scope.call(Source.new(string:, file:, line:), receiver, &block) }
48
-
49
- ensure
50
- Signal.trap("INT", interrupt) if interrupt
51
- instance.shutdown if scope
54
+ execute_source(Source.new(string:, file:, line:), require:, receiver:, &block)
52
55
  end
53
56
 
54
57
  # Executes a file inside a managed Sevgi script scope.
@@ -58,10 +61,18 @@ module Sevgi
58
61
  # @yield optional boot block that installs DSL methods before evaluation
59
62
  # @yieldreturn [void]
60
63
  # @return [Sevgi::Executor::Scope, nil] execution scope, or nil for an empty file
61
- # @raise [Errno::ENOENT] when the file cannot be read
62
- # @raise [LoadError] when the optional required library cannot be loaded
64
+ # @note File-read and required-library load failures are captured as {Sevgi::Executor::Error} on the returned scope.
65
+ # @note Reentrant and concurrent calls keep independent scope stacks per fiber. The temporary SIGINT handler remains
66
+ # process-global while any execution is active.
63
67
  def self.execute_file(file, require: nil, receiver: nil, &block)
64
- execute(::File.read(file), file: file, line: 1, require:, receiver:, &block)
68
+ source = nil
69
+ begin
70
+ source = Source.load(file)
71
+ rescue ::SystemCallError => e
72
+ return capture_error(Source.new(string: "", file:, line: 1), e)
73
+ end
74
+
75
+ execute_source(source, require:, receiver:, &block)
65
76
  end
66
77
 
67
78
  # Removes the current executor scope.
@@ -71,22 +82,94 @@ module Sevgi
71
82
  instance.shutdown
72
83
  end
73
84
 
74
- def initialize = @scopes = []
85
+ def initialize
86
+ @signal_count = 0
87
+ @signal_mutex = Mutex.new
88
+ @signal_previous = nil
89
+ end
75
90
 
76
91
  # Creates and pushes a new executor scope.
77
92
  # @param scope [Module, nil] existing module scope to reuse
78
93
  # @return [Sevgi::Executor::Scope] created scope
79
94
  # @api private
80
- def create(scope = nil) = Scope.new(scope).tap { @scopes << it }
95
+ def create(scope = nil) = Scope.new(scope).tap { scopes << it }
81
96
 
82
97
  # Returns the active executor scope.
83
98
  # @return [Sevgi::Executor::Scope, nil] active scope, if any
84
99
  # @api private
85
- def current = @scopes.last
100
+ def current = scopes.last
101
+
102
+ # Restores the process SIGINT handler when the last active execution ends.
103
+ # @return [void]
104
+ # @api private
105
+ def restore
106
+ @signal_mutex.synchronize do
107
+ next if @signal_count.zero?
108
+
109
+ @signal_count -= 1
110
+ next unless @signal_count.zero?
111
+
112
+ Signal.trap("INT", @signal_previous)
113
+ @signal_previous = nil
114
+ end
115
+ end
86
116
 
87
117
  # Removes the active executor scope.
118
+ # @param scope [Sevgi::Executor::Scope, nil] exact scope to remove, or nil to pop the current scope
88
119
  # @return [Sevgi::Executor::Scope, nil] removed scope
89
120
  # @api private
90
- def shutdown = @scopes.pop
121
+ def shutdown(scope = nil)
122
+ return scopes.pop unless scope
123
+ return scopes.pop if scopes.last.equal?(scope)
124
+
125
+ scopes.delete(scope)
126
+ end
127
+
128
+ # Installs the process SIGINT handler while one or more executions are active.
129
+ # @return [void]
130
+ # @api private
131
+ def trap
132
+ @signal_mutex.synchronize do
133
+ @signal_previous = Signal.trap("INT") { Kernel.abort("") } if @signal_count.zero?
134
+
135
+ @signal_count += 1
136
+ end
137
+ end
138
+
139
+ def self.capture_error(source, error)
140
+ acquired = instance.trap
141
+ scope = instance.create
142
+ scope.capture(source, error)
143
+ ensure
144
+ instance.restore if acquired
145
+ instance.shutdown(scope) if scope
146
+ end
147
+
148
+ def self.execute_source(source, require:, receiver:, &block)
149
+ acquired = false
150
+ return if source.string.empty? && require.nil?
151
+
152
+ acquired = instance.trap
153
+ scope = instance.create
154
+ catch(:result) { run_source(scope, source, require, receiver, &block) }
155
+
156
+ ensure
157
+ instance.restore if acquired
158
+ instance.shutdown(scope) if scope
159
+ end
160
+
161
+ def self.run_source(scope, source, library, receiver, &block)
162
+ ::Kernel.require(library) if library
163
+ scope.call(source, receiver, &block)
164
+ rescue ::LoadError => e
165
+ scope.capture(source, e)
166
+ end
167
+
168
+ private_class_method :capture_error, :execute_source, :run_source
169
+
170
+ private
171
+
172
+ def scopes = Thread.current[SCOPE_KEY] ||= []
91
173
  end
174
+
92
175
  end
@@ -11,7 +11,8 @@ module Sevgi
11
11
  # @param args [Array] arguments forwarded to {Sevgi::Executor.execute}
12
12
  # @param kwargs [Hash] keyword arguments forwarded to {Sevgi::Executor.execute}
13
13
  # @return [Sevgi::Executor::Scope, nil] execution result, or nil for empty source
14
- # @raise [LoadError] when the optional `require:` library cannot be loaded
14
+ # @note Required-library load failures are captured as {Sevgi::Executor::Error} on the returned scope.
15
+ # @note Reentrant and concurrent calls keep independent executor scope stacks per fiber.
15
16
  # @see Sevgi::Executor.execute
16
17
  def self.execute(*args, **kwargs) = Executor.execute(*args, **kwargs, &BootBlock)
17
18
 
@@ -19,8 +20,8 @@ module Sevgi
19
20
  # @param args [Array] arguments forwarded to {Sevgi::Executor.execute_file}
20
21
  # @param kwargs [Hash] keyword arguments forwarded to {Sevgi::Executor.execute_file}
21
22
  # @return [Sevgi::Executor::Scope, nil] execution result, or nil for an empty file
22
- # @raise [Errno::ENOENT] when the file cannot be read
23
- # @raise [LoadError] when the optional `require:` library cannot be loaded
23
+ # @note File-read and required-library load failures are captured as {Sevgi::Executor::Error} on the returned scope.
24
+ # @note Reentrant and concurrent calls keep independent executor scope stacks per fiber.
24
25
  # @see Sevgi::Executor.execute_file
25
26
  def self.execute_file(*args, **kwargs) = Executor.execute_file(*args, **kwargs, &BootBlock)
26
27
 
@@ -30,11 +31,12 @@ module Sevgi
30
31
  # @return [Array<String>] the input file list
31
32
  # @raise [Sevgi::PanicError] when called without an active executor scope
32
33
  # @raise [Sevgi::Error] when a file cannot be located
34
+ # @note `Load` resolves against the active executor scope in the current fiber.
33
35
  def Load(*files)
34
36
  start = ::File.dirname(caller_locations(1..1).first.path)
35
37
 
36
38
  files.each do |file|
37
- location = F.locate(file, start, exclude: start)
39
+ location = F.locate(file, start)
38
40
 
39
41
  ::Sevgi::Executor.load(location.file)
40
42
  end
@@ -4,20 +4,13 @@ require "sevgi/function"
4
4
 
5
5
  module Sevgi
6
6
  module Toplevel
7
- # Function helper namespace promoted as `F` by the full top-level DSL.
7
+ # Promotes the canonical function helper namespace as `F` in the full top-level DSL.
8
8
  #
9
9
  # @example Use helper functions inside script mode
10
10
  # SVG do
11
11
  # text F.pluralize("axis")
12
12
  # end
13
- module Function
14
- extend Sevgi::Function::Color
15
- extend Sevgi::Function::Math
16
- extend Sevgi::Function::Pluralize
17
- extend Sevgi::Function::Shell
18
- extend Sevgi::Function::UI
19
- end
20
-
21
- promote Function, :F
13
+ # @see Sevgi::Function
14
+ promote Sevgi::Function, :F
22
15
  end
23
16
  end
@@ -7,9 +7,12 @@ module Sevgi
7
7
  # Builds a drawable grid from a graphics canvas.
8
8
  # @param canvas [Sevgi::Graphics::Canvas] canvas defining page size and margins
9
9
  # @param unit [Numeric] minor grid unit
10
- # @param multiple [Numeric] number of minor units in each major interval
10
+ # @param multiple [Integer] number of minor units in each major interval
11
11
  # @return [Sevgi::Sundries::Grid] grid fitted to the canvas
12
- # @raise [Sevgi::ArgumentError] when canvas, unit, multiple, or fitting span is invalid
12
+ # @raise [Sevgi::ArgumentError] when canvas is not a graphics canvas
13
+ # @raise [Sevgi::ArgumentError] when unit is not a finite positive number
14
+ # @raise [Sevgi::ArgumentError] when multiple is not a positive integer
15
+ # @raise [Sevgi::ArgumentError] when canvas dimensions, margins, and grid intervals cannot fit
13
16
  def Grid(canvas, unit:, multiple:)
14
17
  ArgumentError.("Must be a Canvas: #{canvas}") unless canvas.is_a?(Graphics::Canvas)
15
18
 
@@ -11,16 +11,19 @@ module Sevgi
11
11
  @constants = {}
12
12
 
13
13
  class << self
14
- attr_reader :constants
14
+ # Returns an immutable snapshot of promoted constants.
15
+ # @return [Hash<Symbol, Object>]
16
+ def constants = @constants.dup.freeze
15
17
 
16
18
  private
17
19
 
18
20
  def inject(base)
19
21
  return if base.instance_variable_defined?("@_toplevel_injected_")
20
22
 
21
- target = base.is_a?(::Module) ? base : base.class
22
- @constants.each do |name, constant|
23
- target.const_set(name, constant) unless target.const_defined?(name, false)
23
+ if base.is_a?(::Module)
24
+ @constants.each do |name, constant|
25
+ base.const_set(name, constant) unless base.const_defined?(name, false)
26
+ end
24
27
  end
25
28
 
26
29
  base.instance_variable_set("@_toplevel_injected_", true)
@@ -31,7 +34,7 @@ module Sevgi
31
34
  end
32
35
  end
33
36
 
34
- # Injects promoted constants when the DSL is included.
37
+ # Injects promoted constants when the DSL is included in a module or class.
35
38
  # @param base [Module] the class or module receiving promoted constants
36
39
  # @return [void]
37
40
  # @api private
@@ -40,9 +43,11 @@ module Sevgi
40
43
  inject(base)
41
44
  end
42
45
 
43
- # Injects promoted constants when the DSL is extended.
44
- # @param base [Object] the object or module receiving promoted constants
46
+ # Injects promoted constants when the DSL is extended by a module.
47
+ # @param base [Object] the object or module receiving the DSL methods
45
48
  # @return [void]
49
+ # @note Constants are promoted only to modules/classes. Extending an ordinary object installs methods without
50
+ # writing constants to `Object`.
46
51
  # @api private
47
52
  def self.extended(base)
48
53
  super
data/lib/sevgi/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Sevgi
4
4
  # Current version of the Sevgi top-level gem.
5
- VERSION = "0.93.1"
5
+ VERSION = "0.95.0"
6
6
  end
data/lib/sevgi.rb CHANGED
@@ -26,9 +26,11 @@ def SVG(...) = Sevgi::Graphics.SVG(...)
26
26
 
27
27
  # Full top-level API for Sevgi library and script consumers.
28
28
  #
29
- # Including or extending this module installs DSL methods such as `Paper`, `Load`,
30
- # and `Grid`, plus convenience constants such as `F`, `Geometry`, `Origin`, and
31
- # `Export`.
29
+ # Including this module in a class or module installs DSL methods such as
30
+ # `Paper`, `Load`, and `Grid`, plus convenience constants such as `F`,
31
+ # `Geometry`, `Origin`, and `Export`. Extending a module does the same.
32
+ # Extending an ordinary object installs the methods only and does not write
33
+ # promoted constants to `Object`.
32
34
  #
33
35
  # @example Include the DSL in an object
34
36
  # class Drawing
@@ -50,6 +52,7 @@ module Sevgi
50
52
  # Installs the full toplevel DSL into an extending object or module.
51
53
  # @param base [Object] the receiver extended with the DSL methods
52
54
  # @return [void]
55
+ # @note Promoted constants are written only when base is a module or class.
53
56
  # @api private
54
57
  def self.extended(base)
55
58
  super