Skip to content

Add support for Ruby Structs #939

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/solargraph/convention.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Convention
autoload :Gemfile, 'solargraph/convention/gemfile'
autoload :Gemspec, 'solargraph/convention/gemspec'
autoload :Rakefile, 'solargraph/convention/rakefile'
autoload :StructDefinition, 'solargraph/convention/struct_definition'

# @type [Set<Convention::Base>]
@@conventions = Set.new
Expand Down
101 changes: 101 additions & 0 deletions lib/solargraph/convention/struct_definition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

module Solargraph
module Convention
module StructDefinition
autoload :StructDefintionNode, 'solargraph/convention/struct_definition/struct_definition_node'
autoload :StructAssignmentNode, 'solargraph/convention/struct_definition/struct_assignment_node'

module NodeProcessors
class StructNode < Parser::NodeProcessor::Base
def process
return if struct_definition_node.nil?

loc = get_node_location(node)
nspin = Solargraph::Pin::Namespace.new(
type: :class,
location: loc,
closure: region.closure,
name: struct_definition_node.class_name,
comments: comments_for(node),
visibility: :public,
gates: region.closure.gates.freeze
)
pins.push nspin

# define initialize method
initialize_method_pin = Pin::Method.new(
name: 'initialize',
parameters: [],
scope: :instance,
location: get_node_location(node),
closure: nspin,
visibility: :private,
comments: comments_for(node)
)

pins.push initialize_method_pin

struct_definition_node.attributes.map do |attribute_node, attribute_name|
initialize_method_pin.parameters.push(
Pin::Parameter.new(
name: attribute_name,
decl: struct_definition_node.keyword_init? ? :kwarg : :arg,
location: get_node_location(attribute_node),
closure: initialize_method_pin
)
)
end

# define attribute accessors and instance variables
struct_definition_node.attributes.each do |attribute_node, attribute_name|
[attribute_name, "#{attribute_name}="].each do |name|
method_pin = Pin::Method.new(
name: name,
parameters: [],
scope: :instance,
location: get_node_location(attribute_node),
closure: nspin,
comments: attribute_comments(attribute_node, attribute_name),
visibility: :public
)

pins.push method_pin

next unless name.include?('=') # setter
pins.push Pin::InstanceVariable.new(name: "@#{attribute_name}",
closure: method_pin,
location: get_node_location(attribute_node),
comments: attribute_comments(attribute_node, attribute_name))
end
end

process_children region.update(closure: nspin, visibility: :public)
end

private

# @return [StructDefintionNode, nil]
def struct_definition_node
@struct_definition_node ||= if StructDefintionNode.valid?(node)
StructDefintionNode.new(node)
elsif StructAssignmentNode.valid?(node)
StructAssignmentNode.new(node)
end
end

# @param attribute_node [Parser::AST::Node]
# @return [String, nil]
def attribute_comments(attribute_node, attribute_name)
struct_comments = comments_for(attribute_node)
return if struct_comments.nil? || struct_comments.empty?

struct_comments.split("\n").find do |row|
row.include?(attribute_name)
end&.gsub('@param', '@return')&.gsub(attribute_name, '')
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

module Solargraph
module Convention
module StructDefinition
# A node wrapper for a Struct definition via const assignment.
# @example
# MyStruct = Struct.new(:bar, :baz) do
# def foo
# end
# end
class StructAssignmentNode < StructDefintionNode
class << self
# @example
# s(:casgn, nil, :Foo,
# s(:block,
# s(:send,
# s(:const, nil, :Struct), :new,
# s(:sym, :bar),
# s(:sym, :baz)),
# s(:args),
# s(:def, :foo,
# s(:args),
# s(:send, nil, :bar))))
def valid?(node)
return false unless node&.type == :casgn
return false if node.children[2].nil?
struct_node = node.children[2].children[0]

struct_definition_node?(struct_node)
end
end

def class_name
if node.children[0]
Parser::NodeMethods.unpack_name(node.children[0]) + "::#{node.children[1]}"
else
node.children[1].to_s
end
end

private

# @return [Parser::AST::Node]
def struct_node
node.children[2].children[0]
end
end
end
end
end
100 changes: 100 additions & 0 deletions lib/solargraph/convention/struct_definition/struct_definition_node.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

module Solargraph
module Convention
module StructDefinition
# A node wrapper for a Struct definition via inheritance.
# @example
# class MyStruct < Struct.new(:bar, :baz)
# def foo
# end
# end
class StructDefintionNode
class << self
# @example
# s(:class,
# s(:const, nil, :Foo),
# s(:send,
# s(:const, nil, :Struct), :new,
# s(:sym, :bar),
# s(:sym, :baz)),
# s(:hash,
# s(:pair,
# s(:sym, :keyword_init),
# s(:true)))),
# s(:def, :foo,
# s(:args),
# s(:send, nil, :bar)))
def valid?(node)
return false unless node&.type == :class

struct_definition_node?(node.children[1])
end

private

# @param struct_node [Parser::AST::Node]
# @return [Boolean]
def struct_definition_node?(struct_node)
return false unless struct_node.is_a?(::Parser::AST::Node)
return false unless struct_node&.type == :send
return false unless struct_node.children[0]&.type == :const
return false unless struct_node.children[0].children[1] == :Struct
return false unless struct_node.children[1] == :new

true
end
end

# @return [Parser::AST::Node]
def initialize(node)
@node = node
end

# @return [String]
def class_name
Parser::NodeMethods.unpack_name(node)
end

# @return [Array<Array(Parser::AST::Node, String)>]
def attributes
struct_attribute_nodes.map do |struct_def_param|
next unless struct_def_param.type == :sym
[struct_def_param, struct_def_param.children[0].to_s]
end.compact
end

def keyword_init?
keyword_init_param = struct_attribute_nodes.find do |struct_def_param|
struct_def_param.type == :hash && struct_def_param.children[0].type == :pair &&
struct_def_param.children[0].children[0].children[0] == :keyword_init
end

return false if keyword_init_param.nil?

keyword_init_param.children[0].children[1].type == :true
end

# @return [Parser::AST::Node]
def body_node
node.children[2]
end

private

# @return [Parser::AST::Node]
attr_reader :node

# @return [Parser::AST::Node]
def struct_node
node.children[1]
end

# @return [Array<Parser::AST::Node>]
def struct_attribute_nodes
struct_node.children[2..-1]
end
end
end
end
end
1 change: 0 additions & 1 deletion lib/solargraph/parser/node_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ module NodeProcessor
autoload :Base, 'solargraph/parser/node_processor/base'

class << self
# @type [Hash<Symbol, Class<NodeProcessor::Base>>]
@@processors ||= {}

# Register a processor for a node type.
Expand Down
2 changes: 1 addition & 1 deletion lib/solargraph/parser/parser_gem/node_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def pack_name(node)
if n.is_a?(AST::Node)
if n.type == :cbase
parts = [''] + pack_name(n)
else
elsif n.type == :const
parts += pack_name(n)
end
else
Expand Down
22 changes: 21 additions & 1 deletion lib/solargraph/parser/parser_gem/node_processors/casgn_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ class CasgnNode < Parser::NodeProcessor::Base
include ParserGem::NodeMethods

def process
if Convention::StructDefinition::StructAssignmentNode.valid?(node)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the fact that I had to change the node processor classes - do you see a better way to avoid that?

My initial attempt was to allow multiple node processors, as I explained in the PR description.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that any solution would have required some sort of change to node processors. At the very least, they would need a mechanism to map anonymous class pins for declarations like class Foo < Struct.new. I'm good with handling it like this for now. We can try exploring more general solutions when we look into adding support for Data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good 🙌!

process_struct_assignment
else
process_constant_assignment
end
end

private

# @return [void]
def process_constant_assignment
pins.push Solargraph::Pin::Constant.new(
location: get_node_location(node),
closure: region.closure,
Expand All @@ -18,7 +29,16 @@ def process
process_children
end

private
# TODO: Move this out of [CasgnNode] once [Solargraph::Parser::NodeProcessor] supports
# multiple processors.
def process_struct_assignment
processor_klass = Convention::StructDefinition::NodeProcessors::StructNode
processor = processor_klass.new(node, region, pins, locals)
processor.process

@pins = processor.pins
@locals = processor.locals
end

# @return [String]
def const_name
Expand Down
31 changes: 26 additions & 5 deletions lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ class NamespaceNode < Parser::NodeProcessor::Base
include ParserGem::NodeMethods

def process
sc = nil
if node.type == :class and !node.children[1].nil?
sc = unpack_name(node.children[1])
superclass_name = nil
superclass_name = unpack_name(node.children[1]) if node.type == :class && node.children[1]&.type == :const

if Convention::StructDefinition::StructDefintionNode.valid?(node)
process_struct_definition
else
process_namespace(superclass_name)
end
end

private

# @param superclass_name [String, nil]
def process_namespace(superclass_name)
loc = get_node_location(node)
nspin = Solargraph::Pin::Namespace.new(
type: node.type,
Expand All @@ -23,15 +33,26 @@ def process
gates: region.closure.gates.freeze
)
pins.push nspin
unless sc.nil?
unless superclass_name.nil?
pins.push Pin::Reference::Superclass.new(
location: loc,
closure: pins.last,
name: sc
name: superclass_name
)
end
process_children region.update(closure: nspin, visibility: :public)
end

# TODO: Move this out of [NamespaceNode] once [Solargraph::Parser::NodeProcessor] supports
# multiple processors.
def process_struct_definition
processor_klass = Convention::StructDefinition::NodeProcessors::StructNode
processor = processor_klass.new(node, region, pins, locals)
processor.process

@pins = processor.pins
@locals = processor.locals
end
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/solargraph/pin/method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ def signature_help
def desc
# ensure the signatures line up when logged
if signatures.length > 1
"\n#{to_rbs}\n"
path + " \n#{to_rbs}\n"
else
to_rbs
super
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/solargraph/pin/namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def desc
if name.nil? || name.empty?
'(top-level)'
else
return_type.rooted_tags
super
end
end

Expand Down
Loading