prompt_manager 1.0.2 → 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 +138 -27
  3. data/README.md +127 -271
  4. data/lib/pm/configuration.rb +34 -4
  5. data/lib/pm/core_directives.rb +21 -20
  6. data/lib/pm/directive.rb +26 -34
  7. data/lib/pm/directives.rb +55 -16
  8. data/lib/pm/errors.rb +71 -0
  9. data/lib/pm/metadata.rb +110 -9
  10. data/lib/pm/parsed.rb +51 -46
  11. data/lib/pm/render_scope.rb +74 -0
  12. data/lib/pm/renderer.rb +59 -0
  13. data/lib/pm/shell.rb +95 -40
  14. data/lib/pm/version.rb +1 -1
  15. data/lib/pm.rb +116 -57
  16. data/lib/prompt_manager.rb +6 -0
  17. metadata +13 -113
  18. data/.envrc +0 -3
  19. data/.irbrc +0 -14
  20. data/COMMITS.md +0 -196
  21. data/Rakefile +0 -12
  22. data/docs/api/configuration.md +0 -59
  23. data/docs/api/constants.md +0 -60
  24. data/docs/api/index.md +0 -14
  25. data/docs/api/metadata.md +0 -99
  26. data/docs/api/parsed.md +0 -98
  27. data/docs/api/pm-module.md +0 -169
  28. data/docs/api/render-context.md +0 -51
  29. data/docs/architecture/design-decisions.md +0 -70
  30. data/docs/architecture/index.md +0 -6
  31. data/docs/architecture/processing-pipeline.md +0 -112
  32. data/docs/assets/css/custom.css +0 -1
  33. data/docs/assets/images/prompt_manager.gif +0 -0
  34. data/docs/assets/images/prompt_manager.mp4 +0 -0
  35. data/docs/examples/ai-agent-prompts.md +0 -173
  36. data/docs/examples/code-review-prompt.md +0 -107
  37. data/docs/examples/index.md +0 -7
  38. data/docs/examples/multi-file-composition.md +0 -123
  39. data/docs/getting-started/configuration.md +0 -106
  40. data/docs/getting-started/index.md +0 -7
  41. data/docs/getting-started/installation.md +0 -34
  42. data/docs/getting-started/quick-start.md +0 -94
  43. data/docs/guides/comment-stripping.md +0 -64
  44. data/docs/guides/custom-directives.md +0 -180
  45. data/docs/guides/erb-rendering.md +0 -102
  46. data/docs/guides/includes.md +0 -184
  47. data/docs/guides/index.md +0 -11
  48. data/docs/guides/parameters.md +0 -96
  49. data/docs/guides/parsing.md +0 -151
  50. data/docs/guides/shell-expansion.md +0 -108
  51. data/docs/index.md +0 -70
  52. data/mkdocs.yml +0 -181
data/lib/pm/directive.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/pm/directive.rb
4
- #
5
3
  # Base class for all directive categories.
6
4
  #
7
5
  # Subclass to define a category of directives. Use `desc` immediately
@@ -22,26 +20,21 @@
22
20
  # alias_method :hi, :greet
23
21
  # end
24
22
  #
23
+ # PM::Directive.register_all
25
24
 
26
25
  module PM
27
26
  class Directive
28
27
  # Centralized list of all subclasses across the hierarchy.
29
- # Initialized here on PM::Directive; the inherited hook always
30
- # appends to this specific array.
31
28
  @directive_subclasses = []
32
29
 
33
30
  class << self
34
31
  # ---- Subclass tracking ------------------------------------------------
35
- # Every subclass (direct or indirect) is tracked in PM::Directive's
36
- # centralized list so register_all can find them all.
37
32
 
38
33
  def inherited(subclass)
39
34
  PM::Directive.directive_subclasses << subclass
40
35
  super
41
36
  end
42
37
 
43
- # Returns the centralized subclass list. Only meaningful when called
44
- # on PM::Directive itself; subclasses delegate here explicitly.
45
38
  def directive_subclasses
46
39
  if equal?(PM::Directive)
47
40
  @directive_subclasses
@@ -64,23 +57,12 @@ module PM
64
57
  directive_descriptions[method_name] = @_pending_desc
65
58
  @_pending_desc = nil
66
59
  else
67
- # Detect aliases created by alias_method.
68
- # UnboundMethod#original_name returns the original method name;
69
- # when it differs from method_name, this method is an alias.
70
- begin
71
- um = instance_method(method_name)
72
- original = um.original_name
73
- if original != method_name && directive_descriptions.key?(original)
74
- (directive_aliases[original] ||= []) << method_name
75
- end
76
- rescue NameError
77
- # ignore — method may reference undefined constants at load time
78
- end
60
+ record_alias(method_name)
79
61
  end
80
62
  super
81
63
  end
82
64
 
83
- # Per-subclass metadata stores (instance variables on the class object).
65
+ # Per-subclass metadata stores.
84
66
 
85
67
  def directive_descriptions
86
68
  @directive_descriptions ||= {}
@@ -92,7 +74,7 @@ module PM
92
74
 
93
75
  # ---- Category name derived from class name ----------------------------
94
76
  # PM::CoreDirectives --> "Core"
95
- # AIA::WebAndFileDirectives --> "Web and File"
77
+ # AIA::WebAndFileDirectives --> "Web And File"
96
78
 
97
79
  def category_name
98
80
  name.split('::').last
@@ -101,7 +83,6 @@ module PM
101
83
  end
102
84
 
103
85
  # ---- Singleton instance per subclass ----------------------------------
104
- # Created by register_all, accessible for tests and state management.
105
86
 
106
87
  def instance
107
88
  @instance
@@ -109,16 +90,17 @@ module PM
109
90
 
110
91
  # ---- Dispatch block builder -------------------------------------------
111
92
  # Override in subclasses to customize how directive methods are called.
112
- # The default passes (ctx, *args) straight through — suitable for
113
- # PM::CoreDirectives whose methods use the RenderContext.
93
+ # The default passes (ctx, *args) straight through.
114
94
 
115
95
  def build_dispatch_block(inst, method_name)
116
96
  proc { |ctx, *args| inst.send(method_name, ctx, *args) }
117
97
  end
118
98
 
119
99
  # ---- PM registration --------------------------------------------------
120
- # Creates one instance per subclass and registers every described
121
- # method (plus its aliases) with PM.
100
+ # Creates one instance per subclass and registers every described method
101
+ # (plus its aliases). Safe to call repeatedly: a subclass may re-register
102
+ # its own names, and a later subclass may override an earlier one. A name
103
+ # claimed with PM.register raises DirectiveAlreadyRegisteredError.
122
104
 
123
105
  def register_all
124
106
  PM::Directive.directive_subclasses.each do |klass|
@@ -129,15 +111,25 @@ module PM
129
111
 
130
112
  klass.directive_descriptions.each_key do |method_name|
131
113
  aliases = klass.directive_aliases[method_name] || []
132
- names = [method_name, *aliases]
133
-
134
- # Remove previously registered names (idempotent re-init)
135
- names.each { |n| PM.directives.delete(n.to_sym) }
136
-
137
- block = klass.build_dispatch_block(inst, method_name)
138
- PM.register(*names, &block)
114
+ block = klass.build_dispatch_block(inst, method_name)
115
+ PM.send(:register_from_class, klass, [method_name, *aliases], block)
139
116
  end
140
117
  end
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ # Detects aliases created by alias_method: UnboundMethod#original_name
124
+ # differs from the method name when the method is an alias.
125
+ def record_alias(method_name)
126
+ um = instance_method(method_name)
127
+ original = um.original_name
128
+ return if original == method_name || !directive_descriptions.key?(original)
129
+
130
+ (directive_aliases[original] ||= []) << method_name
131
+ rescue NameError
132
+ # The method may reference constants that are not yet defined at load time.
141
133
  end
142
134
  end
143
135
  end
data/lib/pm/directives.rb CHANGED
@@ -2,32 +2,71 @@
2
2
 
3
3
  module PM
4
4
  # --- Directive registry ---
5
+ #
6
+ # Two kinds of registration share one namespace:
7
+ # PM.register(:name) { |ctx, *args| ... } -- block directives
8
+ # PM::Directive subclasses + register_all -- class directives
9
+ #
10
+ # Rules:
11
+ # * PM.register raises DirectiveAlreadyRegisteredError if ANY requested name
12
+ # is taken, and registers nothing in that case (atomic).
13
+ # * register_all may re-register a name that a Directive subclass owns
14
+ # (so it is idempotent and a later subclass can override an earlier one),
15
+ # but raises if the name was claimed with PM.register.
5
16
 
6
- @directives = {}
17
+ @directives = {}
18
+ @directive_owners = {} # name => the PM::Directive subclass that registered it
7
19
 
8
- # Registers one or more named directives available in ERB templates.
9
- # The block receives a RenderContext as its first argument,
10
- # followed by any arguments from the ERB call.
11
- # Multiple names register the same block under each name (aliases).
12
- # Raises RuntimeError if any name is already registered.
20
+ # Registers one or more names for the same block. The block receives a
21
+ # RenderContext first, then the arguments from the ERB call.
13
22
  def self.register(*names, &block)
14
- names.each do |name|
15
- name = name.to_sym
16
- if @directives.key?(name)
17
- raise "Directive already registered: #{name}"
18
- end
19
- @directives[name] = block
20
- end
23
+ raise ArgumentError, 'PM.register requires a block' unless block
24
+
25
+ syms = names.map(&:to_sym)
26
+ taken = syms.find { |name| @directives.key?(name) }
27
+ raise DirectiveAlreadyRegisteredError, "Directive already registered: #{taken}" if taken
28
+
29
+ syms.each { |name| @directives[name] = block }
30
+ nil
21
31
  end
22
32
 
23
- # Returns the registered directives hash.
33
+ # A frozen snapshot of the registry (name => block). Mutating it has no
34
+ # effect on PM; use PM.register / PM.reset_directives!.
24
35
  def self.directives
25
- @directives
36
+ @directives.dup.freeze
26
37
  end
27
38
 
28
- # Clears all directives and re-registers from PM::Directive subclasses.
39
+ def self.directive?(name)
40
+ @directives.key?(name.to_sym)
41
+ end
42
+
43
+ # Clears every directive, then re-registers the PM::Directive subclasses.
29
44
  def self.reset_directives!
30
45
  @directives.clear
46
+ @directive_owners.clear
31
47
  PM::Directive.register_all
48
+ nil
49
+ end
50
+
51
+ # Used by PM::Directive.register_all. Not part of the public API.
52
+ def self.register_from_class(klass, names, block)
53
+ syms = names.map(&:to_sym)
54
+
55
+ syms.each do |name|
56
+ next unless @directives.key?(name)
57
+ if @directive_owners[name].nil?
58
+ raise DirectiveAlreadyRegisteredError,
59
+ "Directive already registered with PM.register: #{name}"
60
+ end
61
+
62
+ @directives.delete(name) # owned by a Directive subclass: later registration wins
63
+ end
64
+
65
+ syms.each do |name|
66
+ @directives[name] = block
67
+ @directive_owners[name] = klass
68
+ end
69
+ nil
32
70
  end
71
+ private_class_method :register_from_class
33
72
  end
data/lib/pm/errors.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PM
4
+ # Base class for every error PM raises itself.
5
+ #
6
+ # Errors from the standard library pass through unchanged:
7
+ # Errno::ENOENT -- PM.parse given a file path that does not exist
8
+ # Psych::SyntaxError -- front matter that is not valid YAML
9
+ # Psych::DisallowedClass / Psych::AliasesNotEnabled -- YAML using symbols or anchors
10
+ # SyntaxError -- ERB that does not compile
11
+ class Error < StandardError; end
12
+
13
+ # Front matter was present but not a YAML mapping, or a known key
14
+ # (`shell`, `erb`, `parameters`) had a value of the wrong type.
15
+ class MetadataError < Error; end
16
+
17
+ # Shell expansion could not complete (for example an unmatched `$(`).
18
+ class ShellExpansionError < Error; end
19
+
20
+ # A `$(command)` exited non-zero. Carries the command, its Process::Status,
21
+ # and whatever it wrote to stderr.
22
+ class ShellCommandError < ShellExpansionError
23
+ attr_reader :command, :status, :stderr
24
+
25
+ def initialize(command, status, stderr = '')
26
+ @command = command
27
+ @status = status
28
+ @stderr = stderr.to_s
29
+ super(build_message)
30
+ end
31
+
32
+ private
33
+
34
+ def build_message
35
+ message = "Shell command failed (exit #{status.exitstatus}): #{command}"
36
+ detail = stderr.strip
37
+ detail.empty? ? message : "#{message}\n#{detail}"
38
+ end
39
+ end
40
+
41
+ # Base for problems raised by the built-in `include` directive.
42
+ class IncludeError < Error; end
43
+
44
+ # A file tried to include one of its own ancestors.
45
+ class CircularIncludeError < IncludeError; end
46
+
47
+ # Nesting went past PM.config.max_include_depth.
48
+ class IncludeDepthError < IncludeError; end
49
+
50
+ # `include` was used from a string-parsed prompt that has no directory.
51
+ class IncludeContextError < IncludeError; end
52
+
53
+ # `insert` / `read` named a file that does not exist.
54
+ class FileNotFoundError < Error; end
55
+
56
+ # PM.register (or a PM::Directive subclass) tried to claim a name that is taken.
57
+ class DirectiveAlreadyRegisteredError < Error; end
58
+
59
+ # Parameter problems. Subclasses ArgumentError rather than PM::Error so that
60
+ # `rescue ArgumentError` written against 1.0.x keeps working. Rescue
61
+ # PM::ParameterError when you want only these.
62
+ class ParameterError < ArgumentError; end
63
+
64
+ # A parameter declared with a `null` default was not supplied to `to_s`.
65
+ # The message always starts with "Missing required parameters:".
66
+ class MissingParameterError < ParameterError; end
67
+
68
+ # Strict mode only: a template referenced a name that is neither a
69
+ # parameter nor a registered directive.
70
+ class UnknownParameterError < ParameterError; end
71
+ end
data/lib/pm/metadata.rb CHANGED
@@ -1,17 +1,118 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
-
5
3
  module PM
6
- # OpenStruct-based metadata with predicate methods for boolean keys.
7
- class Metadata < OpenStruct
4
+ # Schemaless front-matter container with dot-notation access.
5
+ #
6
+ # meta = PM::Metadata.new('title' => 'Hi', shell: true)
7
+ # meta.title #=> "Hi"
8
+ # meta[:title] #=> "Hi"
9
+ # meta['title'] #=> "Hi"
10
+ # meta.shell? #=> true (predicates exist only for true/false values)
11
+ # meta.missing #=> nil
12
+ # meta.other = 1 # setter
13
+ # meta.to_h #=> {title: "Hi", shell: true, other: 1} (Symbol keys)
14
+ #
15
+ # Keys are stored as Symbols; `[]`, `[]=`, and `key?` accept Strings or Symbols.
16
+ # A key whose name collides with a public Object method (`hash`, `display`, ...)
17
+ # still works through dot notation because an accessor is defined for it.
18
+ class Metadata
19
+ include Enumerable
20
+
21
+ # Object methods that must never be shadowed by a metadata key.
22
+ PROTECTED_METHODS = %i[
23
+ class object_id send public_send __send__ __id__ respond_to? respond_to_missing?
24
+ method_missing instance_variable_get instance_variable_set instance_variables
25
+ is_a? kind_of? instance_of? freeze frozen? dup clone itself then tap
26
+ to_h to_s inspect == != equal? eql? each each_pair key? keys values
27
+ fetch dig delete empty? size length to_enum enum_for
28
+ ].to_set.freeze
29
+
8
30
  def initialize(hash = {})
9
- super(hash)
10
- hash.each do |key, value|
11
- if value.is_a?(TrueClass) || value.is_a?(FalseClass)
12
- define_singleton_method(:"#{key}?") { send(key) }
13
- end
31
+ @table = {}
32
+ hash.each { |key, value| self[key] = value }
33
+ end
34
+
35
+ def [](key)
36
+ @table[key.to_sym]
37
+ end
38
+
39
+ def []=(key, value)
40
+ sym = key.to_sym
41
+ @table[sym] = value
42
+ define_accessor(sym) if shadows_object_method?(sym)
43
+ value
44
+ end
45
+
46
+ def key?(key) = @table.key?(key.to_sym)
47
+
48
+ def keys = @table.keys
49
+ def values = @table.values
50
+ def size = @table.size
51
+ alias length size
52
+ def empty? = @table.empty?
53
+
54
+ def fetch(key, *default, &block) = @table.fetch(key.to_sym, *default, &block)
55
+
56
+ def dig(key, *rest)
57
+ value = self[key]
58
+ rest.empty? || value.nil? ? value : value.dig(*rest)
59
+ end
60
+
61
+ def delete(key) = @table.delete(key.to_sym)
62
+
63
+ def each(&block)
64
+ return to_enum(:each) unless block
65
+
66
+ @table.each(&block)
67
+ self
68
+ end
69
+ alias each_pair each
70
+
71
+ def to_h = @table.dup
72
+
73
+ def ==(other)
74
+ other.is_a?(Metadata) && to_h == other.to_h
75
+ end
76
+
77
+ def inspect = "#<PM::Metadata #{@table.inspect}>"
78
+ alias to_s inspect
79
+
80
+ def method_missing(name, *args, &block)
81
+ key = name.to_s
82
+
83
+ if key.end_with?('=') && args.size == 1
84
+ self[key.chomp('=')] = args.first
85
+ elsif key.end_with?('?') && args.empty? && boolean_key?(key.chomp('?'))
86
+ self[key.chomp('?')]
87
+ elsif args.empty? && !key.end_with?('?', '!')
88
+ self[key] # nil for unknown keys, matching 1.0.x OpenStruct behaviour
89
+ else
90
+ super
14
91
  end
15
92
  end
93
+
94
+ def respond_to_missing?(name, include_private = false)
95
+ key = name.to_s
96
+ return true if key.end_with?('=')
97
+ return boolean_key?(key.chomp('?')) if key.end_with?('?')
98
+ return false if key.end_with?('!')
99
+
100
+ key?(key) || super
101
+ end
102
+
103
+ private
104
+
105
+ def boolean_key?(key)
106
+ value = self[key]
107
+ value.equal?(true) || value.equal?(false)
108
+ end
109
+
110
+ def shadows_object_method?(sym)
111
+ !PROTECTED_METHODS.include?(sym) && Object.public_method_defined?(sym)
112
+ end
113
+
114
+ def define_accessor(sym)
115
+ define_singleton_method(sym) { @table[sym] }
116
+ end
16
117
  end
17
118
  end
data/lib/pm/parsed.rb CHANGED
@@ -1,59 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
- require 'ostruct'
5
- require 'erb'
3
+ require 'yaml'
6
4
 
7
5
  module PM
8
- # Render context passed to registered directives.
9
- RenderContext = Struct.new(:directory, :params, :included, :depth, :metadata, keyword_init: true)
10
-
11
- Parsed = Struct.new(:metadata, :content, keyword_init: true) do
12
- def [](key)
13
- metadata[key]
6
+ # Passed as the first argument to every directive.
7
+ #
8
+ # directory -- directory of the file being rendered (nil for string prompts)
9
+ # params -- merged parameter values (String keys)
10
+ # included -- Set of ancestor file paths, used for circular-include detection
11
+ # depth -- include nesting depth (0 for the top-level prompt)
12
+ # metadata -- the current file's PM::Metadata
13
+ RenderContext = Data.define(:directory, :params, :included, :depth, :metadata) do
14
+ def initialize(directory: nil, params: {}, included: Set.new, depth: 0, metadata: nil)
15
+ super(directory: directory, params: params, included: included, depth: depth, metadata: metadata)
14
16
  end
17
+ end
18
+
19
+ # Metadata keys PM adds itself when parsing a file, or fills in while rendering.
20
+ # They are left out of #to_markdown so a prompt round-trips cleanly.
21
+ DERIVED_METADATA_KEYS = %i[directory name created_at modified_at includes].freeze
22
+
23
+ # The result of PM.parse: front matter plus content with shell references
24
+ # already expanded. Call #to_s to render the ERB.
25
+ Parsed = Data.define(:metadata, :content) do
26
+ def [](key) = metadata[key]
27
+
28
+ def key?(key) = metadata.key?(key)
29
+
30
+ # The `parameters` mapping from the front matter ({} when absent).
31
+ def parameters = metadata.parameters || {}
15
32
 
16
- # Returns the prompt content with ERB tags expanded.
17
- # Registered directives are available as methods in the ERB binding.
18
- # Raises if any required parameters (default: null) are not provided.
19
- # When metadata has erb: false, returns content without ERB processing.
33
+ # Renders the content with ERB. Registered directives and parameters are
34
+ # available as bare identifiers. Raises PM::MissingParameterError (an
35
+ # ArgumentError) if a `null`-default parameter is not supplied.
36
+ # With `erb: false` the content is returned untouched.
20
37
  def to_s(values = {})
21
- render_with(values, Set.new, 0)
38
+ Renderer.new(self).render(values)
22
39
  end
23
40
 
41
+ # Rendering entry point used by the `include` directive, which must pass
42
+ # the active include chain and depth through.
24
43
  def render_with(values, included, depth)
25
- metadata.includes = []
26
- return content unless metadata.erb?
27
-
28
- defaults = metadata.parameters || {}
29
- params = defaults.merge(values.transform_keys(&:to_s))
30
-
31
- missing = params.select { |_, v| v.nil? }.keys
32
- unless missing.empty?
33
- raise ArgumentError, "Missing required parameters: #{missing.join(', ')}"
34
- end
35
-
36
- if metadata.directory && metadata.name
37
- included.add(File.join(metadata.directory, metadata.name))
38
- end
39
-
40
- context = OpenStruct.new(params)
41
-
42
- render_ctx = PM::RenderContext.new(
43
- directory: metadata.directory,
44
- params: params,
45
- included: included,
46
- depth: depth,
47
- metadata: metadata
48
- )
49
-
50
- PM.directives.each do |name, block|
51
- context.define_singleton_method(name) do |*args|
52
- block.call(render_ctx, *args)
53
- end
54
- end
55
-
56
- ERB.new(content).result(context.instance_eval { binding })
44
+ Renderer.new(self).render(values, included: included, depth: depth)
45
+ end
46
+
47
+ def to_h
48
+ { metadata: metadata.to_h, content: content }
49
+ end
50
+
51
+ # The prompt as a markdown file again: YAML front matter followed by the
52
+ # content. Keys PM derived from the file system (directory, name, timestamps)
53
+ # and the `includes` tree are omitted. `shell` and `erb` are always written,
54
+ # since their values were resolved against the global config at parse time.
55
+ # Content is the parsed content, so shell references have already been expanded.
56
+ def to_markdown
57
+ authored = metadata.to_h.reject { |key, _| DERIVED_METADATA_KEYS.include?(key) }
58
+ return content if authored.empty?
59
+
60
+ yaml = YAML.dump(authored.transform_keys(&:to_s)).delete_prefix("---\n")
61
+ "---\n#{yaml}---\n#{content}"
57
62
  end
58
63
  end
59
64
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PM
4
+ # The object whose binding an ERB template is evaluated in.
5
+ #
6
+ # A bare identifier in a template resolves here, in this order:
7
+ # 1. a parameter of that name, when called with no arguments
8
+ # 2. a registered directive of that name (called with the RenderContext first)
9
+ # 3. nil -- or PM::UnknownParameterError when PM.config.strict_parameters is on
10
+ #
11
+ # So a parameter shadows a zero-argument directive call of the same name, and
12
+ # a directive call with arguments is never confused with a parameter.
13
+ class RenderScope
14
+ def initialize(params, directives, context, strict: false)
15
+ @params = params
16
+ @directives = directives
17
+ @context = context
18
+ @strict = strict
19
+ shadow_object_methods
20
+ end
21
+
22
+ # The binding ERB renders in. A method rather than instance_eval so the
23
+ # only locals visible to the template are ERB's own.
24
+ def erb_binding = binding
25
+
26
+ def method_missing(name, *args, &block)
27
+ key = name.to_s
28
+
29
+ if args.empty? && @params.key?(key)
30
+ @params[key]
31
+ elsif (directive = @directives[name])
32
+ directive.call(@context, *args, &block)
33
+ elsif @strict
34
+ raise UnknownParameterError, "Unknown parameter or directive: #{name}"
35
+ elsif args.empty?
36
+ nil
37
+ else
38
+ super
39
+ end
40
+ end
41
+
42
+ def respond_to_missing?(name, include_private = false)
43
+ @params.key?(name.to_s) || @directives.key?(name) || super
44
+ end
45
+
46
+ private
47
+
48
+ # Never shadowed: erb_binding calls `binding`.
49
+ UNSHADOWED = %i[binding].freeze
50
+
51
+ # A parameter named like an Object method -- public (`hash`, `display`)
52
+ # or private Kernel (`format`, `test`, `system`) -- would otherwise never
53
+ # reach method_missing. Called bare it is the parameter; called with
54
+ # arguments or a block it is still the method.
55
+ def shadow_object_methods
56
+ @params.each_key do |key|
57
+ sym = key.to_sym
58
+ next unless shadowable?(sym)
59
+
60
+ define_singleton_method(sym) do |*args, **kwargs, &block|
61
+ next @params[key] if args.empty? && kwargs.empty? && block.nil?
62
+
63
+ super(*args, **kwargs, &block)
64
+ end
65
+ end
66
+ end
67
+
68
+ def shadowable?(sym)
69
+ return false if Metadata::PROTECTED_METHODS.include?(sym) || UNSHADOWED.include?(sym)
70
+
71
+ Object.public_method_defined?(sym) || Object.private_method_defined?(sym)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module PM
6
+ # Turns a Parsed prompt into its final string: merges parameters, checks the
7
+ # required ones, builds the RenderContext, and evaluates ERB.
8
+ #
9
+ # `included` is the set of ancestor file paths (the active include chain).
10
+ # It is copied, never mutated, so sibling includes do not see each other.
11
+ class Renderer
12
+ def initialize(parsed)
13
+ @parsed = parsed
14
+ @metadata = parsed.metadata
15
+ end
16
+
17
+ def render(values = {}, included: Set.new, depth: 0)
18
+ @metadata.includes = []
19
+ return @parsed.content unless @metadata.erb?
20
+
21
+ params = merge_params(values)
22
+ context = build_context(params, included, depth)
23
+ scope = RenderScope.new(params, PM.directives, context, strict: PM.config.strict_parameters?)
24
+
25
+ ERB.new(@parsed.content).result(scope.erb_binding)
26
+ end
27
+
28
+ private
29
+
30
+ def merge_params(values)
31
+ defaults = (@metadata.parameters || {}).transform_keys(&:to_s)
32
+ params = defaults.merge(values.transform_keys(&:to_s))
33
+
34
+ missing = params.select { |_, value| value.nil? }.keys
35
+ raise MissingParameterError, "Missing required parameters: #{missing.join(', ')}" unless missing.empty?
36
+
37
+ params
38
+ end
39
+
40
+ def build_context(params, included, depth)
41
+ ancestors = included.dup
42
+ ancestors << own_path if own_path
43
+
44
+ RenderContext.new(
45
+ directory: @metadata.directory,
46
+ params: params,
47
+ included: ancestors,
48
+ depth: depth,
49
+ metadata: @metadata
50
+ )
51
+ end
52
+
53
+ def own_path
54
+ return nil unless @metadata.directory && @metadata.name
55
+
56
+ File.join(@metadata.directory, @metadata.name)
57
+ end
58
+ end
59
+ end