lowkey 0.4.3 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23f484029089a05bd14c027fdd8f8282afd3264c18e45c1c7cfe18237f09bf7a
4
- data.tar.gz: 432014f7350e077fdf38610deb9cd76b6ae8f35d08b6128e2a3bafa543f019d0
3
+ metadata.gz: 1a54c7a7c2d38827bf0eec31f56a83ca054e46b44312ee2ead8a39d0630f00c8
4
+ data.tar.gz: 4e77aca4128ce3cec24cd077c75922aa8a2ef22e6cccb76fefbab0cbfe41f5c0
5
5
  SHA512:
6
- metadata.gz: a4012dbb3d72a2ff26984a1347f4c7465d1fc12bab6faf1cb7f95ed089d2eccc254832653d69f1fe7faf73812770f5717ab1fcd2eb642db941169091a968b89d
7
- data.tar.gz: c6c5f289593a2bb2ff626649d4a36e3bb71bd4ff19607ea3ba417e5188c64d5e125d6bdca133cab2275b952dd5820a646262844f57b8f77fbd89bc7302b6d077
6
+ metadata.gz: 5a76a77c867d1051d553f8af3309dc389b3d8b37e168cd7152e954625306f2666dd4ab8b3ac78c2a5c2063a4adfa7b738a5f3cadb220c3bd6804fedeaf42dd8d
7
+ data.tar.gz: ee32de9dd6b3fe80c96422744ba8077605babe0ed3d5ffaa9a28a8609fd20873bd404dd04b3e453628b6567e909575c33f9159fa65120a891e319df15372327e
@@ -12,8 +12,10 @@ module Lowkey
12
12
 
13
13
  def load # rubocop:disable Metrics/AbcSize
14
14
  # Type check return values.
15
- @class_proxy.method_calls.each do |method_call_node|
16
- next unless %i[get post patch put delete options].include?(method_call_node.name)
15
+ @class_proxy.method_calls.each do |method_call|
16
+ next unless %i[get post patch put delete options].include?(method_call.name)
17
+
18
+ method_call_node = method_call.node
17
19
 
18
20
  arguments_node = method_call_node.compact_child_nodes.first
19
21
  next unless arguments_node.is_a?(Prism::ArgumentsNode)
@@ -23,7 +25,7 @@ module Lowkey
23
25
  name = route
24
26
  source = SourceFactory.method_call_source(method_call_node:, arguments_node:, file_path:, lines:)
25
27
 
26
- next unless (return_proxy = ProxyFactory.return_proxy(method_node: method_call_node, name:, source:))
28
+ next unless (return_proxy = ProxyFactory.return_proxy(method_node: method_call_node, name:, file_path:, lines:))
27
29
 
28
30
  param_proxies = [ParamProxy.new(source:, name:, type: :pos_req, position: 0)]
29
31
  method_proxy = MethodProxy.new(source:, name:, param_proxies:, return_proxy:)
@@ -5,6 +5,7 @@ require_relative '../factories/source_factory'
5
5
  require_relative '../proxies/body_proxy'
6
6
  require_relative '../proxies/class_proxy'
7
7
  require_relative '../proxies/file_proxy'
8
+ require_relative '../proxies/method_call_proxy'
8
9
  require_relative '../proxies/method_proxy'
9
10
  require_relative '../proxies/module_proxy'
10
11
  require_relative '../proxies/param_proxy'
@@ -37,9 +38,15 @@ module Lowkey
37
38
 
38
39
  param_proxies = param_proxies(parameters_node: method_node.parameters, file_path: file_proxy.file_path, source: method_source)
39
40
  body_proxy = BodyProxy.new(name:, source: body_source)
40
- return_proxy = return_proxy(name:, method_node:, source: method_source)
41
+ return_proxy = return_proxy(name:, method_node:, file_path: file_proxy.file_path, lines: file_proxy.lines)
42
+ signature_end_line = signature_end_node(method_node:, return_proxy:)&.end_line || method_node.start_line
41
43
 
42
- MethodProxy.new(name:, source: method_source, param_proxies:, body_proxy:, return_proxy:)
44
+ MethodProxy.new(name:, source: method_source, param_proxies:, body_proxy:, return_proxy:, signature_end_line:,
45
+ receiver: method_node.receiver&.slice, node: method_node)
46
+ end
47
+
48
+ def method_call_proxy(call_node:, file_path:, lines:)
49
+ MethodCallProxy.new(name: call_node.name, node: call_node, file_path:, lines:)
43
50
  end
44
51
 
45
52
  def param_proxies(parameters_node:, source:, file_path:)
@@ -57,23 +64,53 @@ module Lowkey
57
64
  name = param.name
58
65
  source = SourceFactory.param_source(param_node: param, file_path:, lines: source.lines)
59
66
  type = param_types[param.class]
60
- value = param.respond_to?(:value) ? param.value.slice : ':LOWKEY_UNDEFINED'
67
+ value = param.respond_to?(:value) ? param.value.slice : :LOWKEY_UNDEFINED
61
68
 
62
- ParamProxy.new(name:, source:, type:, position:, value:)
69
+ ParamProxy.new(name:, source:, type:, position:, value:, default: param_default(param:))
63
70
  end
64
71
  end
65
72
 
66
- def return_proxy(method_node:, name:, source:)
73
+ def return_proxy(method_node:, name:, file_path:, lines:)
67
74
  return_node = find_return_node(method_node:)
68
75
  return nil if return_node.nil?
69
76
 
70
77
  value = return_node.body.slice
78
+ source = SourceFactory.return_source(return_node:, scope: name, file_path:, lines:)
71
79
 
72
80
  ReturnProxy.new(name:, source:, value:)
73
81
  end
74
82
 
83
+ # The default value of a param or argument is the last value in a chain of "|" operators: "Type | Type | 'default'".
84
+ # LowType's "value(Type)" wraps a type to be used as a default value.
85
+ def default_source(node:)
86
+ node = node.arguments.arguments.first if union?(node:)
87
+ node = node.arguments.arguments.first if value_call?(node:)
88
+
89
+ node.slice
90
+ end
91
+
92
+ def union?(node:)
93
+ node.is_a?(Prism::CallNode) && node.name == :| && node.arguments&.arguments&.one?
94
+ end
95
+
75
96
  private
76
97
 
98
+ def value_call?(node:)
99
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && %i[value low_value].include?(node.name) &&
100
+ node.arguments&.arguments&.one?
101
+ end
102
+
103
+ # The last node of a method's signature: "def name(params) -> { ReturnType }".
104
+ def signature_end_node(method_node:, return_proxy:)
105
+ return find_return_node(method_node:) if return_proxy
106
+
107
+ method_node.rparen_loc || method_node.parameters || method_node.name_loc
108
+ end
109
+
110
+ def param_default(param:)
111
+ param.respond_to?(:value) ? default_source(node: param.value) : nil
112
+ end
113
+
77
114
  # A return type is an unassigned lambda defined immediately after a method's parameters/block.
78
115
  def find_return_node(method_node:)
79
116
  # Method statements.
@@ -34,7 +34,7 @@ module Lowkey
34
34
  # TODO: Brittle conditional. What we really want to identify is if the method/rest of file was able to be parsed into an AST.
35
35
  end_line = end_line_from_indent(method_node:, lines:) if end_line >= lines.count
36
36
 
37
- Source.new(file_path:, scope:, lines:, start_line:, end_line:)
37
+ Source.new(file_path:, scope:, lines:, start_line:, end_line:, location: method_node.location)
38
38
  end
39
39
 
40
40
  def param_source(param_node:, file_path:, lines:)
@@ -42,7 +42,14 @@ module Lowkey
42
42
  start_line = param_node.start_line
43
43
  end_line = param_node.end_line
44
44
 
45
- Source.new(file_path:, scope:, lines:, start_line:, end_line:)
45
+ Source.new(file_path:, scope:, lines:, start_line:, end_line:, location: param_node.location)
46
+ end
47
+
48
+ def return_source(return_node:, scope:, file_path:, lines:)
49
+ start_line = return_node.start_line
50
+ end_line = return_node.end_line
51
+
52
+ Source.new(file_path:, scope:, lines:, start_line:, end_line:, location: return_node.location)
46
53
  end
47
54
 
48
55
  def body_source(method_source:)
@@ -53,6 +60,11 @@ module Lowkey
53
60
  Source.new(file_path: method_source.file_path, scope: method_source.scope, lines: method_source.lines, start_line:, end_line:)
54
61
  end
55
62
 
63
+ def call_source(call_node:, file_path:, lines:)
64
+ Source.new(file_path:, scope: call_node.name, lines:, start_line: call_node.start_line, end_line: call_node.end_line,
65
+ location: call_node.location)
66
+ end
67
+
56
68
  def method_call_source(method_call_node:, arguments_node:, file_path:, lines:)
57
69
  pattern = arguments_node.arguments.first.content
58
70
  scope = "#{method_call_node.name.upcase} #{pattern}"
@@ -1,21 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
4
-
5
3
  module Lowkey
6
4
  # Proxy and Source abstract away nodes and avoid referencing the node on the proxy itself. Will this change?
7
5
  # The goal is to make an API that's indiependent of the AST, that can manipulate source code line by line.
6
+ #
7
+ # Methods delegate to the source by hand, as Forwardable allocates an array of arguments on every call.
8
+ # Subclasses can build their source on first access by overriding "source".
8
9
  class Proxy
9
- extend Forwardable
10
-
11
- attr_reader :name
12
-
13
- def_delegators :@source, :file_path, :lines, :lines=, :start_line, :end_line, :scope
14
- def_delegators :@source, :wrap, :export
10
+ attr_reader :name, :source
15
11
 
16
12
  def initialize(name:, source:)
17
13
  @name = name
18
14
  @source = source
19
15
  end
16
+
17
+ def file_path = source.file_path
18
+ def scope = source.scope
19
+
20
+ def lines = source.lines
21
+
22
+ def lines=(new_lines)
23
+ source.lines = new_lines
24
+ end
25
+
26
+ def start_line = source.start_line
27
+ def end_line = source.end_line
28
+ def start_index = source.start_index
29
+ def end_index = source.end_index
30
+
31
+ def start_offset = source.start_offset
32
+ def end_offset = source.end_offset
33
+ def start_column = source.start_column
34
+ def end_column = source.end_column
35
+ def line_offset = source.line_offset
36
+
37
+ def wrap(prefix:, suffix:) = source.wrap(prefix:, suffix:)
38
+ def export = source.export
20
39
  end
21
40
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lowkey
4
+ class ReleasedNodeError < StandardError
5
+ def initialize(message = 'Prism nodes are released once a file loads. Set "Lowkey.config.release_nodes = false" to keep them.')
6
+ super
7
+ end
8
+ end
9
+
10
+ # A proxy whose Prism node is released once its file loads, as nodes use most of a cached file's memory.
11
+ module ReleasableNode
12
+ def node
13
+ raise ReleasedNodeError if @node_released
14
+
15
+ @node
16
+ end
17
+
18
+ def release_node
19
+ @node = nil
20
+ @node_released = true
21
+ end
22
+ end
23
+ end
data/lib/lowkey.rb CHANGED
@@ -9,17 +9,22 @@ require_relative 'visitors/visitor'
9
9
  require_relative 'proxies/file_proxy'
10
10
 
11
11
  module Lowkey
12
+ NAMESPACE_PATTERN = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
13
+
12
14
  class << self
13
15
  def keys
14
16
  @keys ||= {}
15
17
  end
16
18
 
19
+ # A namespace returns its file proxies. A file path returns its file proxy, loading the file on first access.
17
20
  def [](key)
18
- keys[key]
21
+ return keys[key] if keys.key?(key) || !file_path?(key)
22
+
23
+ load(key)
19
24
  end
20
25
 
21
- def load(file_path, cache: true)
22
- root_node = Prism.parse_file(file_path).value
26
+ def load(file_path, cache: true, release_nodes: config.release_nodes)
27
+ root_node = Prism.parse(File.read(file_path), filepath: file_path).value
23
28
  file_proxy = ProxyFactory.file_proxy(root_node:, file_path:)
24
29
 
25
30
  parent_map = ParentMap.new(root_node:)
@@ -33,16 +38,35 @@ module Lowkey
33
38
  map_definitions(file_proxy:)
34
39
  end
35
40
 
41
+ file_proxy.release_nodes if release_nodes
36
42
  file_proxy
37
43
  end
38
44
 
39
- def clear
40
- keys.clear
45
+ def kept
46
+ @kept ||= Set.new
47
+ end
48
+
49
+ # Keeps a file proxy in the cache when it's cleared, for gems that look it up after the app has loaded.
50
+ def keep(file_proxy)
51
+ kept << file_proxy
52
+ end
53
+
54
+ # Clears cached file proxies to save memory, such as in a boot file once an app has loaded.
55
+ # Kept file proxies stay along with their namespaces, unless "keep" is false.
56
+ def clear(keep: true)
57
+ kept.clear unless keep
58
+
59
+ keys.select! do |_key, value|
60
+ next kept.include?(value) unless value.is_a?(Array)
61
+
62
+ value.select! { |file_proxy| kept.include?(file_proxy) }
63
+ !value.empty?
64
+ end
41
65
  end
42
66
 
43
67
  def config
44
- config = Struct.new(:cache)
45
- @config ||= config.new(true)
68
+ config = Struct.new(:cache, :release_nodes)
69
+ @config ||= config.new(true, true)
46
70
  end
47
71
 
48
72
  def configure
@@ -51,6 +75,10 @@ module Lowkey
51
75
 
52
76
  private
53
77
 
78
+ def file_path?(key)
79
+ key.is_a?(String) && !key.match?(NAMESPACE_PATTERN) && File.file?(key)
80
+ end
81
+
54
82
  def map_file_path(file_proxy:)
55
83
  keys[file_proxy.file_path] = file_proxy
56
84
 
@@ -2,8 +2,12 @@
2
2
 
3
3
  module Lowkey
4
4
  class ParentMap
5
+ # The namespace of each node, cached by Query#namespace.
6
+ attr_reader :namespaces
7
+
5
8
  def initialize(root_node:)
6
9
  @parent_map = {}
10
+ @namespaces = {}
7
11
  @current_parent = nil
8
12
 
9
13
  map(root_node)
data/lib/models/source.rb CHANGED
@@ -4,16 +4,22 @@ module Lowkey
4
4
  class WriteError < StandardError; end
5
5
 
6
6
  class Source
7
- attr_reader :file_path, :scope, :lines
7
+ attr_reader :file_path, :scope, :lines, :start_offset, :end_offset, :start_column, :end_column
8
8
  attr_accessor :start_line, :end_line
9
9
 
10
- def initialize(file_path:, scope:, lines:, start_line:, end_line: nil)
10
+ # Offsets are byte offsets of the node in the original file. Columns are byte columns.
11
+ def initialize(file_path:, scope:, lines:, start_line:, end_line: nil, location: nil) # rubocop:disable Metrics/ParameterLists
11
12
  @file_path = file_path
12
13
  @scope = scope
13
14
 
14
15
  @start_line = start_line
15
16
  @end_line = end_line
16
17
  @lines = lines
18
+
19
+ @start_offset = location&.start_offset
20
+ @end_offset = location&.end_offset
21
+ @start_column = location&.start_column
22
+ @end_column = location&.end_column
17
23
  end
18
24
 
19
25
  def lines=(new_lines)
@@ -38,7 +44,25 @@ module Lowkey
38
44
  lines[start_index..end_index].join
39
45
  end
40
46
 
41
- private
47
+ # Applies edits of [start_offset, end_offset, text] to a range of lines, keeping the amount of lines the same so that
48
+ # other sources sharing the lines stay valid. Offsets are byte offsets from the start of the range's first line.
49
+ def rewrite_lines(range:, edits:)
50
+ new_lines = apply_edits(text: lines[range].join.b, edits:).force_encoding(encoding).lines
51
+ raise WriteError, "More new lines than old lines, won't fit" if new_lines.count > range.count
52
+
53
+ lines[range] = new_lines + Array.new(range.count - new_lines.count, '')
54
+ end
55
+
56
+ def encoding
57
+ lines.first&.encoding || Encoding::UTF_8
58
+ end
59
+
60
+ # Edits are applied from last to first so that their offsets stay valid.
61
+ def apply_edits(text:, edits:)
62
+ edits.sort_by { |start_offset, _end_offset, _text| -start_offset }.reduce(text) do |new_text, (start_offset, end_offset, edit)|
63
+ new_text.byteslice(0, start_offset) + edit.b + new_text.byteslice(end_offset..)
64
+ end
65
+ end
42
66
 
43
67
  def start_index
44
68
  start_line - 1
@@ -47,5 +71,10 @@ module Lowkey
47
71
  def end_index
48
72
  end_line - 1
49
73
  end
74
+
75
+ # The byte offset of the start of the first line.
76
+ def line_offset
77
+ start_offset - start_column
78
+ end
50
79
  end
51
80
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../interfaces/proxy'
4
+ require_relative '../interfaces/releasable_node'
4
5
  require_relative '../proxies/class_proxy'
5
6
  require_relative '../queries/query'
6
7
 
@@ -8,7 +9,6 @@ module Lowkey
8
9
  class FileProxy < Proxy
9
10
  include Query
10
11
 
11
- attr_reader :root_node
12
12
  attr_accessor :definitions, :dependencies
13
13
 
14
14
  def initialize(root_node:, source:)
@@ -22,7 +22,20 @@ module Lowkey
22
22
 
23
23
  def [](keypath)
24
24
  namespace, *file_path = keypath.split('.')
25
- file_path.empty? ? @definitions[namespace] : query(node: @root_node, namespace:, name: file_path.join)
25
+ file_path.empty? ? @definitions[namespace] : query(node: root_node, namespace:, name: file_path.join)
26
+ end
27
+
28
+ def root_node
29
+ raise ReleasedNodeError if @nodes_released
30
+
31
+ @root_node
32
+ end
33
+
34
+ # Prism's syntax tree uses most of a cached file proxy's memory, so it's released once the file is loaded.
35
+ def release_nodes
36
+ @root_node = nil
37
+ @nodes_released = true
38
+ @definitions.each_value(&:release_nodes)
26
39
  end
27
40
 
28
41
  def []=(keypath, value)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../factories/source_factory'
4
+ require_relative '../interfaces/proxy'
5
+ require_relative '../interfaces/releasable_node'
6
+
7
+ module Lowkey
8
+ # A method call like "include Module", "attr_reader :name" or "type(String | 'default')".
9
+ # Details, including the source, are read from the node on first access as consumers often only need a few method calls.
10
+ class MethodCallProxy < Proxy
11
+ include ReleasableNode
12
+
13
+ def initialize(name:, node:, file_path:, lines:)
14
+ super(name:, source: nil)
15
+
16
+ @node = node
17
+ @file_path = file_path
18
+ @lines = lines
19
+ @rewritten = false
20
+ end
21
+
22
+ # The file path and lines don't need the node, so they're still available once nodes are released.
23
+ attr_reader :file_path, :lines
24
+
25
+ def source
26
+ @source ||= SourceFactory.call_source(call_node: node, file_path: @file_path, lines: @lines)
27
+ end
28
+
29
+ # The source of the receiver in "receiver.method", such as "self".
30
+ def receiver
31
+ return @receiver if defined?(@receiver)
32
+
33
+ @receiver = node.receiver&.slice
34
+ end
35
+
36
+ def receiver?
37
+ !receiver.nil?
38
+ end
39
+
40
+ # The source of each argument.
41
+ def arguments
42
+ @arguments ||= argument_nodes.map(&:slice)
43
+ end
44
+
45
+ # The keys of a hash argument like "method key: value" when every key is a symbol, otherwise nil.
46
+ def keywords
47
+ return @keywords if defined?(@keywords)
48
+
49
+ hash = argument_nodes.last
50
+ symbol_keys = (hash.is_a?(Prism::KeywordHashNode) || hash.is_a?(Prism::HashNode)) &&
51
+ hash.elements.all? { |element| element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) }
52
+
53
+ @keywords = symbol_keys ? hash.elements.map { |element| element.key.unescaped.to_sym } : nil
54
+ end
55
+
56
+ # The source of the default value when the argument is "Type | 'default'".
57
+ def default
58
+ return @default if defined?(@default)
59
+
60
+ union = argument_nodes.one? && ProxyFactory.union?(node: argument_nodes.first)
61
+ @default = union ? ProxyFactory.default_source(node: argument_nodes.first) : nil
62
+ end
63
+
64
+ # Replaces the method call with text, once. Calls on the same line must be rewritten from last to first.
65
+ def rewrite(text)
66
+ return if @rewritten
67
+
68
+ source.rewrite_lines(range: start_index..end_index, edits: [[start_offset - line_offset, end_offset - line_offset, text]])
69
+ @rewritten = true
70
+ end
71
+
72
+ # Removes the method call, along with its lines when it's the only code on them, and any blank line left orphaned.
73
+ def remove
74
+ return if @rewritten
75
+ return rewrite('') unless own_lines?
76
+
77
+ (start_index..end_index).each { |index| lines[index] = '' }
78
+ remove_orphaned_blank_line
79
+ @rewritten = true
80
+ end
81
+
82
+ private
83
+
84
+ def argument_nodes
85
+ node.arguments&.arguments || []
86
+ end
87
+
88
+ def own_lines?
89
+ lines[start_index].b.byteslice(0, start_column).strip.empty? && lines[end_index].b.byteslice(end_column..).strip.empty?
90
+ end
91
+
92
+ # A blank line after a removed line is orphaned when it follows another blank line or opens a class/module.
93
+ def remove_orphaned_blank_line
94
+ next_index = ((end_index + 1)...lines.count).find { |index| lines[index] != '' }
95
+ return unless next_index && lines[next_index].strip.empty?
96
+
97
+ lines[next_index] = '' if orphaning_line?(line: previous_line(index: start_index))
98
+ end
99
+
100
+ def orphaning_line?(line:)
101
+ line.nil? || line.strip.empty? || line.match?(/\A\s*(?:class|module)\b/)
102
+ end
103
+
104
+ # Skips removed lines, which are empty strings rather than blank lines.
105
+ def previous_line(index:)
106
+ previous_index = (0...index).reverse_each.find { |line_index| lines[line_index] != '' }
107
+ previous_index ? lines[previous_index] : nil
108
+ end
109
+ end
110
+ end
@@ -1,36 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../interfaces/proxy'
4
+ require_relative '../interfaces/releasable_node'
4
5
  require_relative '../queries/query'
5
6
 
6
7
  module Lowkey
7
8
  class MethodProxy < Proxy
8
9
  include Query
10
+ include ReleasableNode
9
11
 
10
- attr_reader :params, :body, :return_proxy
12
+ attr_reader :params, :body, :return_proxy, :signature_end_line, :receiver
11
13
 
12
- def initialize(name:, source:, param_proxies: [], body_proxy: nil, return_proxy: nil)
14
+ # @param receiver - The source of the receiver in "def receiver.method", such as "self".
15
+ # @param node - The method's node, which is released once its file loads.
16
+ def initialize(name:, source:, param_proxies: [], body_proxy: nil, return_proxy: nil, signature_end_line: nil, receiver: nil, # rubocop:disable Metrics/ParameterLists
17
+ node: nil)
13
18
  super(name:, source:)
14
19
 
20
+ @node = node
21
+ @receiver = receiver
15
22
  @params = param_proxies
16
23
  @body = body_proxy
17
24
  @return_proxy = return_proxy
25
+ @signature_end_line = signature_end_line || source.start_line
26
+ @rewritten = false
18
27
 
19
28
  @named_params = name_params
20
29
  @tagged_params = tag_params
21
30
  end
22
31
 
32
+ # Methods built from method calls, like Sinatra routes, have no method node to query.
23
33
  def [](key)
24
- # TODO: Initialize method proxy with method node and support query code path.
25
- key.start_with?('.') ? query(node: @node, namespace: nil, name: key.delete_prefix('.')) : @named_params[key]
34
+ return @named_params[key] unless key.start_with?('.')
35
+
36
+ query(node:, namespace: nil, name: key.delete_prefix('.')) if node
26
37
  end
27
38
 
28
39
  def tagged_params(tag)
29
40
  @tagged_params[tag] || []
30
41
  end
31
42
 
43
+ # Removes types from the signature, leaving the rest of the source as is. The amount of lines stays the same.
44
+ def rewrite_signature
45
+ return if @rewritten
46
+
47
+ signature_range = start_index..(signature_end_line - 1)
48
+
49
+ edits = param_edits
50
+ edits << return_type_edit(signature: lines[signature_range].join.b) if @return_proxy&.expression
51
+
52
+ @source.rewrite_lines(range: signature_range, edits:)
53
+ @rewritten = true
54
+ end
55
+
56
+ # Whether the method is defined on its receiver, such as "def self.method", rather than on the current class.
57
+ def receiver?
58
+ !@receiver.nil?
59
+ end
60
+
32
61
  def expressions?
33
- @params.any?(&:expression) || @return_proxy
62
+ @params.any?(&:expression) || !@return_proxy&.expression.nil?
34
63
  end
35
64
 
36
65
  def params_with_expressions
@@ -39,6 +68,24 @@ module Lowkey
39
68
 
40
69
  private
41
70
 
71
+ def param_edits
72
+ params_with_expressions.map do |param|
73
+ [param.start_offset - line_offset, param.end_offset - line_offset, param.export(typed: false)]
74
+ end
75
+ end
76
+
77
+ # Also removes the whitespace before the return type, or the whole line when the return type is on its own line.
78
+ def return_type_edit(signature:)
79
+ start_offset = @return_proxy.start_offset - line_offset
80
+ end_offset = @return_proxy.end_offset - line_offset
81
+ line_start_offset = start_offset - @return_proxy.start_column
82
+ before = signature.byteslice(line_start_offset, @return_proxy.start_column)
83
+
84
+ return [line_start_offset, end_offset + 1, ''] if before.strip.empty? && signature.byteslice(end_offset) == "\n"
85
+
86
+ [start_offset - before[/[ \t]*\z/].bytesize, end_offset, '']
87
+ end
88
+
42
89
  def name_params
43
90
  @params.each_with_object({}) do |param, named_params|
44
91
  named_params[param.name] = param if %i[pos_req pos_opt key_req key_opt].include?(param.type)
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../interfaces/proxy'
4
+ require_relative '../interfaces/releasable_node'
4
5
  require_relative '../queries/query'
5
6
 
6
7
  module Lowkey
7
8
  class ModuleProxy < Proxy
8
9
  include Query
10
+ include ReleasableNode
9
11
 
10
12
  attr_reader :namespace
11
- attr_accessor :private_start_line, :keyed_methods, :class_methods, :method_calls
13
+ attr_accessor :private_start_line, :keyed_methods, :class_methods
12
14
 
13
15
  def initialize(node:, name:, namespace:, source:)
14
16
  super(name:, source:)
@@ -21,24 +23,105 @@ module Lowkey
21
23
  @keyed_methods = {}
22
24
  @class_methods = {}
23
25
 
24
- @method_calls = []
26
+ @method_call_nodes = []
27
+ @method_calls = nil
28
+ end
29
+
30
+ # Method call proxies are lazy loaded and may not always be needed.
31
+ def method_calls
32
+ @method_calls ||= method_call_nodes.map { |call_node| method_call_proxy(call_node:) }
33
+ end
34
+
35
+ def add_method_call(node:)
36
+ @method_call_nodes << node
37
+ @method_calls << method_call_proxy(call_node: node) if @method_calls
25
38
  end
26
39
 
27
40
  def [](key)
28
- key.start_with?('.') ? query(node: @node, namespace: nil, name: key.delete_prefix('.')) : @keyed_methods[key]
41
+ key.start_with?('.') ? query(node:, namespace: nil, name: key.delete_prefix('.')) : @keyed_methods[key]
42
+ end
43
+
44
+ # Also releases the nodes of its methods and method calls.
45
+ def release_nodes
46
+ release_node
47
+ @method_call_nodes = nil
48
+ @method_calls&.each(&:release_node)
49
+ method_proxies.each(&:release_node)
29
50
  end
30
51
 
31
52
  class << self
53
+ # "def self.method", "def ClassName.method" or a method within "class << self"/"class << ClassName".
32
54
  def class_method?(method_node:, parent_map:)
33
- return true if method_node.is_a?(::Prism::DefNode) && method_node.receiver.instance_of?(Prism::SelfNode) # self.method_name
34
- return true if method_node.is_a?(::Prism::SingletonClassNode) # class << self
55
+ return class_receiver?(receiver: method_node.receiver, node: method_node, parent_map:) if method_node.receiver
56
+ return false unless (singleton_class_node = singleton_class_node(method_node:, parent_map:))
35
57
 
36
- if (parent_node = parent_map[method_node])
37
- return class_method?(method_node: parent_node, parent_map:)
38
- end
58
+ class_receiver?(receiver: singleton_class_node.expression, node: singleton_class_node, parent_map:)
59
+ end
39
60
 
61
+ # A method defined on an object, like "def object.method" or a method within "class << object".
62
+ def singleton_method?(method_node:, parent_map:)
63
+ !method_node.receiver.nil? || !singleton_class_node(method_node:, parent_map:).nil?
64
+ end
65
+
66
+ # Any other receiver, like "def object.method", defines a method on that object rather than the class.
67
+ def class_receiver?(receiver:, node:, parent_map:)
68
+ receiver = unwrap(node: receiver)
69
+ return true if receiver.is_a?(::Prism::SelfNode)
70
+ return false unless receiver.is_a?(::Prism::ConstantReadNode) || receiver.is_a?(::Prism::ConstantPathNode)
71
+
72
+ # The receiver can be the class name or any part of its namespace, like "Name" or "Namespace::Name".
73
+ "::#{namespace(node:, parent_map:)}".end_with?("::#{receiver.full_name}")
74
+ rescue ::Prism::ConstantPathNode::DynamicPartsInConstantPathError
40
75
  false
41
76
  end
77
+
78
+ private
79
+
80
+ def singleton_class_node(method_node:, parent_map:)
81
+ node = parent_map[method_node]
82
+ node = parent_map[node] until node.nil? || module_node?(node) || node.is_a?(::Prism::SingletonClassNode)
83
+
84
+ node.is_a?(::Prism::SingletonClassNode) ? node : nil
85
+ end
86
+
87
+ # "def (Namespace::Name).method" wraps the receiver in parentheses.
88
+ def unwrap(node:)
89
+ return node unless node.is_a?(::Prism::ParenthesesNode)
90
+
91
+ body = node.body
92
+ body.is_a?(::Prism::StatementsNode) && body.body.one? ? body.body.first : body
93
+ end
94
+
95
+ def namespace(node:, parent_map:)
96
+ names = []
97
+
98
+ while (node = parent_map[node])
99
+ names.unshift(node.constant_path.full_name) if module_node?(node)
100
+ end
101
+
102
+ names.join('::')
103
+ end
104
+
105
+ def module_node?(node)
106
+ node.is_a?(::Prism::ClassNode) || node.is_a?(::Prism::ModuleNode)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # Method proxies with the same name share a key in "keyed_methods", so instance and class methods are included.
113
+ def method_proxies
114
+ [*@keyed_methods.values, *@class_methods.values, *(instance_methods.values if respond_to?(:instance_methods))].uniq
115
+ end
116
+
117
+ def method_call_nodes
118
+ raise ReleasedNodeError if @method_call_nodes.nil?
119
+
120
+ @method_call_nodes
121
+ end
122
+
123
+ def method_call_proxy(call_node:)
124
+ ProxyFactory.method_call_proxy(call_node:, file_path:, lines:)
42
125
  end
43
126
  end
44
127
  end
@@ -5,14 +5,17 @@ require_relative '../interfaces/proxy'
5
5
  module Lowkey
6
6
  class ParamProxy < Proxy
7
7
  attr_reader :type, :position, :value
8
- attr_accessor :expression
8
+ attr_accessor :default, :expression
9
9
 
10
- def initialize(name:, source:, type:, position: nil, value: :LOWKEY_UNDEFINED, expression: nil) # rubocop:disable Metrics/ParameterLists
10
+ # @param value - The source of the param's value, such as "String | 'default'".
11
+ # @param default - The source of the default value within the value, such as "'default'". Can be overridden.
12
+ def initialize(name:, source:, type:, position: nil, value: :LOWKEY_UNDEFINED, default: nil, expression: nil) # rubocop:disable Metrics/ParameterLists
11
13
  super(name:, source:)
12
14
 
13
15
  @type = type
14
16
  @position = position
15
17
  @value = value
18
+ @default = default
16
19
 
17
20
  @expression = expression
18
21
  end
@@ -20,5 +23,37 @@ module Lowkey
20
23
  def required?
21
24
  @value == :LOWKEY_UNDEFINED
22
25
  end
26
+
27
+ def export(typed: true)
28
+ return @source.export if typed
29
+ return untyped_keyword_param if %i[key_req key_opt].include?(@type)
30
+
31
+ untyped_positional_param
32
+ end
33
+
34
+ private
35
+
36
+ def untyped_keyword_param
37
+ default = resolved_default
38
+ default.nil? ? "#{@name}:" : "#{@name}: #{default}"
39
+ end
40
+
41
+ def untyped_positional_param
42
+ default = resolved_default
43
+ default.nil? ? @name.to_s : "#{@name} = #{default}"
44
+ end
45
+
46
+ # Uses the source of the default value rather than the evaluated default value, to preserve the developer's code.
47
+ def resolved_default
48
+ if @expression
49
+ return nil if @expression.required?
50
+
51
+ return @default
52
+ end
53
+
54
+ return nil if @value == :LOWKEY_UNDEFINED
55
+
56
+ @value
57
+ end
23
58
  end
24
59
  end
data/lib/queries/query.rb CHANGED
@@ -11,18 +11,33 @@ module Lowkey
11
11
  end
12
12
  end
13
13
 
14
- def namespace(node:, parent_map:, namespace: [])
14
+ def namespace(node:, parent_map:)
15
+ path = namespace_path(node:, parent_map:)
16
+ return nil if path.nil?
17
+
18
+ path.empty? ? 'Object' : path
19
+ end
20
+
21
+ private
22
+
23
+ # Each node caches the namespace above it, so our first ancestor will have the full namespace.
24
+ def namespace_path(node:, parent_map:)
25
+ namespaces = parent_map.namespaces
26
+ return namespaces[node] if namespaces.key?(node)
27
+
28
+ namespaces[node] = resolve_namespace_path(node:, parent_map:)
29
+ end
30
+
31
+ def resolve_namespace_path(node:, parent_map:)
15
32
  # When inside RBX, Prism thinks we're defining a class because of "<".
16
33
  return nil if node.respond_to?(:constant_path) && node.constant_path.is_a?(Prism::MissingNode)
34
+ return '' if (parent = parent_map[node]).nil?
17
35
 
18
- if parent_map[node].nil?
19
- namespace << 'Object' if namespace.empty?
20
- return namespace.reverse.join('::')
21
- end
22
-
23
- namespace << node.constant_path.name.to_s if node.respond_to?(:constant_path)
36
+ path = namespace_path(node: parent, parent_map:)
37
+ return path if path.nil? || !node.respond_to?(:constant_path)
24
38
 
25
- namespace(node: parent_map[node], parent_map:, namespace:)
39
+ name = node.constant_path.name.to_s
40
+ (path.empty? ? name : "#{path}::#{name}").freeze
26
41
  end
27
42
  end
28
43
  end
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lowkey
4
- VERSION = '0.4.3'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -15,7 +15,7 @@ module Lowkey
15
15
  namespace = namespace(node:, parent_map:) || return
16
16
  module_proxy = file_proxy[namespace]
17
17
 
18
- module_proxy.method_calls << node
18
+ module_proxy.add_method_call(node:)
19
19
  upsert_dependency(node:, namespace:)
20
20
 
21
21
  return unless node.name == :private && node.respond_to?(:start_line) && module_proxy.start_line && module_proxy.end_line
@@ -20,17 +20,20 @@ module Lowkey
20
20
  method_proxy = ProxyFactory.method_proxy(method_node:, file_proxy:)
21
21
 
22
22
  module_proxy.keyed_methods[method_node.name] = method_proxy
23
+ tag_method(method_node:, method_proxy:, module_proxy:)
24
+ end
25
+
26
+ private
23
27
 
24
- # TODO: Implemented as tagged methods similar to tagged params.
28
+ # TODO: Implemented as tagged methods similar to tagged params.
29
+ def tag_method(method_node:, method_proxy:, module_proxy:)
25
30
  if ModuleProxy.class_method?(method_node:, parent_map:)
26
31
  module_proxy.class_methods[method_node.name] = method_proxy
27
- elsif module_proxy.class <= ClassProxy
32
+ elsif module_proxy.class <= ClassProxy && !ModuleProxy.singleton_method?(method_node:, parent_map:)
28
33
  module_proxy.instance_methods[method_node.name] = method_proxy
29
34
  end
30
35
  end
31
36
 
32
- private
33
-
34
37
  attr_reader :file_proxy, :parent_map
35
38
  end
36
39
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lowkey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: prism
@@ -37,12 +37,14 @@ files:
37
37
  - lib/factories/source_factory.rb
38
38
  - lib/interfaces/adapter.rb
39
39
  - lib/interfaces/proxy.rb
40
+ - lib/interfaces/releasable_node.rb
40
41
  - lib/lowkey.rb
41
42
  - lib/maps/parent_map.rb
42
43
  - lib/models/source.rb
43
44
  - lib/proxies/body_proxy.rb
44
45
  - lib/proxies/class_proxy.rb
45
46
  - lib/proxies/file_proxy.rb
47
+ - lib/proxies/method_call_proxy.rb
46
48
  - lib/proxies/method_proxy.rb
47
49
  - lib/proxies/module_proxy.rb
48
50
  - lib/proxies/param_proxy.rb
@@ -73,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
75
  - !ruby/object:Gem::Version
74
76
  version: '0'
75
77
  requirements: []
76
- rubygems_version: 4.0.6
78
+ rubygems_version: 3.6.2
77
79
  specification_version: 4
78
80
  summary: An API for accessing PRISM metadata easily and efficiently
79
81
  test_files: []