diff --git a/.rubocop.yml b/.rubocop.yml index 74c60ae..302030b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,7 +14,7 @@ plugins: - rubocop-rake AllCops: - TargetRubyVersion: 3.1 + TargetRubyVersion: 3.1 # 4.0 not yet afailable Tue Dec 30 15:07:23 PST 2025 NewCops: enable Exclude: - 'spec/fixture_files/**/*' diff --git a/Dockerfile b/Dockerfile index cb0af89..272ad4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,10 @@ RUN gem update bundler # Needed for byebug and some other gems RUN apk update -RUN apk add curl make g++ sqlite-dev yaml-dev +# changes as of ruby 4: +# ncurses for guard +# linux-headers to build some io code - maybe to do with sockets +RUN apk add curl make g++ sqlite-dev yaml-dev linux-headers ncurses WORKDIR /usr/local/src RUN curl -O -L https://github.com/mateusza/SQLite-Levenshtein/archive/master.zip diff --git a/Gemfile.lock b/Gemfile.lock index 03b415d..27b92aa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,7 @@ PATH fuzzy_match json ostruct + prism sqlite3 GEM @@ -45,11 +46,14 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.3) - debug (1.10.0) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) drb (2.2.1) + erb (6.0.1) etc (1.4.5) + ffi (1.17.2) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-x86_64-linux-gnu) formatador (1.1.0) @@ -74,8 +78,9 @@ GEM rubocop (< 2.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.7.1) - irb (1.13.1) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.11.3) @@ -87,7 +92,9 @@ GEM logger (1.7.0) lumberjack (1.2.10) method_source (1.1.0) - minitest (5.25.5) + mini_portile2 (2.8.9) + minitest (6.0.1) + prism (~> 1.5) minitest-reporters (1.7.1) ansi builder @@ -103,11 +110,15 @@ GEM parser (3.3.8.0) ast (~> 2.4.1) racc - prism (1.4.0) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.7.0) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.1.2) + psych (5.3.1) + date stringio racc (1.8.1) rainbow (3.1.1) @@ -115,10 +126,13 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (6.6.3.1) + rb-readline (0.5.5) + rdoc (7.0.3) + erb psych (>= 4.0.0) + tsort regexp_parser (2.10.0) - reline (0.5.10) + reline (0.6.3) io-console (~> 0.5) rubocop (1.75.5) json (~> 2.3) @@ -151,20 +165,23 @@ GEM ruby-progressbar (1.13.0) securerandom (0.4.1) shellany (0.0.1) + sqlite3 (2.6.0) + mini_portile2 (~> 2.8.0) sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.6.0-x86_64-linux-gnu) - stringio (3.1.1) + stringio (3.2.0) sync (0.5.0) thor (1.4.0) timeout (0.4.3) tins (1.38.0) bigdecimal sync + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-emoji (4.2.0) uri (1.0.3) PLATFORMS @@ -179,6 +196,7 @@ DEPENDENCIES minitest minitest-reporters rake + rb-readline rubocop (> 1.38.0) rubocop-ast (> 1.32.0) rubocop-minitest diff --git a/Guardfile b/Guardfile index c867531..e6ecece 100644 --- a/Guardfile +++ b/Guardfile @@ -18,7 +18,7 @@ # and, you'll have to watch "config/Guardfile" instead of "Guardfile" group :red_green_refactor, halt_on_fail: true do - guard :minitest, all_after_pass: true, cli: '--guard' do + guard :minitest, all_after_pass: true, spring: 'rake test' do # with Minitest::Unit # watch(%r{^test/(.*)\/?test_(.*)\.rb$}) # watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" } diff --git a/Makefile b/Makefile index ce7ea66..4c7d0b6 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,9 @@ test: image shell: image docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) sh +run_in_shell: image + docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) sh -c '$(COMMAND)' + # Just to make sure it works. server: image docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) diff --git a/lib/db/schema.rb b/lib/db/schema.rb index 3c3ad71..ffbd42c 100644 --- a/lib/db/schema.rb +++ b/lib/db/schema.rb @@ -18,6 +18,7 @@ def write(*args) t.string :superclass_name t.string :path t.string :class_type, null: false + t.text :parameters # JSON string of method parameters end add_index :scopes, :name diff --git a/lib/ruby_language_server/completion.rb b/lib/ruby_language_server/completion.rb index 5aa27af..0907a32 100644 --- a/lib/ruby_language_server/completion.rb +++ b/lib/ruby_language_server/completion.rb @@ -36,16 +36,46 @@ def completion(context, context_scope, position_scopes) { isIncomplete: true, items: completions.uniq.map do |word, hash| - { + item = { label: word, kind: COMPLETION_ITEM_KIND[hash[:type]&.to_sym] } + + # Add snippet for methods with parameters + if hash[:type] == 'method' && hash[:parameters]&.any? + item[:insertText] = generate_method_snippet(word, hash[:parameters]) + item[:insertTextFormat] = 2 # Snippet format + end + + item end } end private + def generate_method_snippet(method_name, parameters) + return method_name if parameters.empty? + + # Build snippet with tab stops + param_snippets = [] + tab_index = 1 + + parameters.each do |param| + param_snippets << case param['type'] + when 'keyword' + # Keyword args with placeholder for value + "#{param['name']} ${#{tab_index}:value}" + else + # All other param types use the parameter name + "${#{tab_index}:#{param['name']}}" + end + tab_index += 1 + end + + "#{method_name}(#{param_snippets.join(', ')})" + end + def scopes_with_name(name, scopes) return scopes.where(name:) if scopes.respond_to?(:where) @@ -86,7 +116,12 @@ def scope_completions(word, scopes) variable_words = RubyLanguageServer::ScopeData::Variable.where(scope_id: scope_ids).closest_to(word).limit(5).map { |variable| [variable.name, variable.scope] } words = (scope_words + variable_words).to_h good_words = FuzzyMatch.new(words.keys, threshold: 0.01).find_all(word).slice(0..10) || [] - words = good_words.each_with_object({}) { |w, hash| hash[w] = {depth: words[w].depth, type: words[w].class_type} }.to_h + good_words.each_with_object({}) do |w, hash| + scope = words[w] + hash[w] = {depth: scope.depth, type: scope.class_type} + # Include parameters for methods + hash[w][:parameters] = scope.parsed_parameters if scope.method? && scope.parameters.present? + end end end end diff --git a/lib/ruby_language_server/scope_data/scope.rb b/lib/ruby_language_server/scope_data/scope.rb index 18bc5bb..140e920 100644 --- a/lib/ruby_language_server/scope_data/scope.rb +++ b/lib/ruby_language_server/scope_data/scope.rb @@ -24,12 +24,13 @@ class Scope < Base # attr_accessor :name # method # attr_accessor :superclass_name # superclass name - def self.build(parent = nil, type = TYPE_ROOT, name = '', top_line = 1, column = 1) + def self.build(parent = nil, type = TYPE_ROOT, name = '', top_line = 1, column = 1, bottom_line = nil) full_name = [parent&.full_name, name].compact.join(JoinHash[type]) create!( parent:, top_line:, column:, + bottom_line:, name:, path: full_name, class_type: type @@ -79,14 +80,23 @@ def named_scope? [TYPE_MODULE, TYPE_CLASS, TYPE_METHOD, TYPE_VARIABLE].include?(class_type) end - # Called from ScopeParser when a peer of this block starts - because we don't have an end notifier. - # So we do some reasonable cleanup here. - def close(line) - return destroy! if block_scope? && variables.none? + # Get parameters as an array of hashes + def parsed_parameters + return [] unless parameters.present? - self.top_line ||= variables.map(&:top_line).min - self.bottom_line = [bottom_line, line].compact.min - save! + JSON.parse(parameters) + rescue JSON::ParserError + [] + end + + # Set parameters from an array + def set_parameters(params_array) + self.parameters = params_array.to_json if params_array.present? + end + + # Called from ScopeParser to cleanup empty blocks. + def close + destroy! if block_scope? && variables.none? end private diff --git a/lib/ruby_language_server/scope_parser.rb b/lib/ruby_language_server/scope_parser.rb index af65d5c..29117c4 100644 --- a/lib/ruby_language_server/scope_parser.rb +++ b/lib/ruby_language_server/scope_parser.rb @@ -1,255 +1,433 @@ # frozen_string_literal: true -require 'ripper' +require 'prism' require_relative 'scope_parser_commands/rake_commands' require_relative 'scope_parser_commands/rspec_commands' require_relative 'scope_parser_commands/ruby_commands' require_relative 'scope_parser_commands/rails_commands' module RubyLanguageServer - # This class is responsible for processing the generated sexp from the ScopeParser below. + # This class is responsible for processing the AST from Prism. # It builds scopes that amount to heirarchical arrays with information about what # classes, methods, variables, etc - are in each scope. - class SEXPProcessor + class PrismProcessor < Prism::Visitor include ScopeParserCommands::RakeCommands include ScopeParserCommands::RspecCommands include ScopeParserCommands::RailsCommands include ScopeParserCommands::RubyCommands - attr_reader :sexp, :lines, :current_scope + attr_reader :current_scope, :lines - def initialize(sexp, lines = 1, shallow = false) - @sexp = sexp + def initialize(lines = 1, shallow = false) @lines = lines @shallow = shallow @root_scope = nil + @current_scope = nil + @block_names = {} end def root_scope - return @root_scope unless @root_scope.nil? - - @root_scope = ScopeData::Scope.where(path: nil, class_type: ScopeData::Scope::TYPE_ROOT).first_or_create! - @current_scope = @root_scope - process(@sexp) - @root_scope + @root_scope ||= begin + scope = ScopeData::Scope.where(path: nil, class_type: ScopeData::Scope::TYPE_ROOT).first_or_create! + @current_scope = scope + scope + end end - def process(sexp) - return if sexp.nil? - - root, args, *rest = sexp - # RubyLanguageServer.logger.error("Doing #{[root, args, rest]}") - case root - when Array - sexp.each { |child| process(child) } - when Symbol - root = root.to_s.gsub(/^@+/, '') - method_name = "on_#{root}" - if respond_to? method_name - send(method_name, args, rest) - else - RubyLanguageServer.logger.debug("We don't have a #{method_name} with #{args}") - process(args) - end - when String - # We really don't do anything with it! - RubyLanguageServer.logger.debug("We don't do Strings like #{root} with #{args}") - when NilClass, FalseClass - process(args) - else - RubyLanguageServer.logger.warn("We don't respond to the likes of #{root} of class #{root.class}") + # Visit a class node + def visit_class_node(node) + name = constant_path_name(node.constant_path) + line = node.location.start_line + end_line = node.location.end_line + column = node.location.start_column + + scope = push_scope(ScopeData::Scope::TYPE_CLASS, name, line, column, end_line) + + # Handle superclass + if node.superclass + superclass_name = constant_path_name(node.superclass) + scope.set_superclass_name(superclass_name) if superclass_name end - end - def on_sclass(_args, rest) - process(rest) + super + pop_scope end - # foo = bar -- bar is in the vcall. Pretty sure we don't want to remember this. - def on_vcall(_args, rest) - # Seriously - discard args. Maybe process rest? - process(rest) - end + # Visit a module node + def visit_module_node(node) + name = constant_path_name(node.constant_path) + line = node.location.start_line + end_line = node.location.end_line + column = node.location.start_column - def on_program(args, _rest) - process(args) + push_scope(ScopeData::Scope::TYPE_MODULE, name, line, column, end_line) + super + pop_scope end - def on_var_field(args, rest) - (_, name, (line, column)) = args - return if name.nil? + # Visit a def node + def visit_def_node(node) + name = node.name.to_s + line = node.location.start_line + end_line = node.location.end_line + column = node.location.start_column - if name.start_with?('@') - add_ivar(name, line, column) + scope = push_scope(ScopeData::Scope::TYPE_METHOD, name, line, column, end_line, false) + + # Extract and store parameters + if node.parameters + params_data = extract_parameters_data(node.parameters) + scope.set_parameters(params_data) if params_data.any? + scope.save! + end + + # Process parameters (adds them as variables in scope) + visit_parameters(node.parameters) if node.parameters + + # Process body only if not shallow + if @shallow + # Skip body processing else - add_variable(name, line, column) + super end - process(rest) - end - def on_bodystmt(args, _rest) - process(args) + pop_scope + scope end - def on_module(args, rest) - scope = add_scope(args.last, rest, ScopeData::Scope::TYPE_MODULE) - assign_subclass(scope, rest) + # Visit a singleton class (class << self) + def visit_singleton_class_node(node) # rubocop:disable Lint/UselessMethodDefinition + # For class << self, we visit the body but don't create a new scope + # The methods defined inside will be class methods of the current scope + super end - def on_class(args, rest) - scope = add_scope(args.last, rest, ScopeData::Scope::TYPE_CLASS) - assign_subclass(scope, rest) - end + # Visit block nodes + def visit_block_node(node) + line = node.location.start_line + end_line = node.location.end_line + column = node.location.start_column - def assign_subclass(scope, sexp) - return unless !sexp[0].nil? && sexp[0][0] == :var_ref + # Use block-specific name if set by command handler, otherwise use 'block' + name = @block_names.delete(node.object_id) || 'block' - (_, (_, name)) = sexp[0] - scope.set_superclass_name(name) - end + push_scope(ScopeData::Scope::TYPE_BLOCK, name, line, column, end_line, false) - def on_method_add_block(args, rest) - scope = @current_scope - process(args) - process(rest) - # add_scope(args, rest, ScopeData::Scope::TYPE_BLOCK) - unless @current_scope == scope - scope.bottom_line = [scope&.bottom_line, @current_scope.bottom_line].compact.max - scope.save! - pop_scope - end - end + # Process block parameters + visit_block_parameters(node.parameters) if node.parameters - def on_do_block(args, rest) - ((_, ((_, (_, (_, _name, (line, column))))))) = args - push_scope(ScopeData::Scope::TYPE_BLOCK, 'block', line, column, false) - process(args) - process(rest) + super pop_scope end - def on_block_var(args, rest) - process(args) - process(rest) + # Visit local variable write nodes + def visit_local_variable_write_node(node) + name = node.name.to_s + line = node.location.start_line + column = node.location.start_column + + add_variable(name, line, column) + super end - # Used only to describe subclasses? -- nope - def on_var_ref(_args, _rest) - # [:@const, "Bar", [13, 20]] - # (_, name) = args - # @current_scope.set_superclass_name(name) + # Visit instance variable write nodes + def visit_instance_variable_write_node(node) + name = node.name.to_s + line = node.location.start_line + column = node.location.start_column + + add_ivar(name, line, column) + super end - def on_assign(args, rest) - process(args) - process(rest) + # Visit constant write nodes + # FOO = 42 + def visit_constant_write_node(node) + name = node.name.to_s + line = node.location.start_line + column = node.location.start_column + + add_variable(name, line, column) + super end - def on_def(args, rest) - add_scope(args, rest, ScopeData::Scope::TYPE_METHOD) + # Visit multi-write nodes (parallel assignment) + def visit_multi_write_node(node) + node.lefts.each do |target| + case target + when Prism::LocalVariableTargetNode + add_variable(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::InstanceVariableTargetNode + add_ivar(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::MultiTargetNode + # Handle nested destructuring like (a, (b, c)) = ... + visit_multi_target(target) + end + end + + # Handle rest parameter in multi-assignment + if node.rest.is_a?(Prism::SplatNode) && node.rest.expression + case node.rest.expression + when Prism::LocalVariableTargetNode + add_variable(node.rest.expression.name.to_s, node.rest.expression.location.start_line, node.rest.expression.location.start_column) + end + end + + super end - # def self.something(par)... - # [:var_ref, [:@kw, "self", [28, 14]]], [[:@period, ".", [28, 18]], [:@ident, "something", [28, 19]], [:paren, [:params, [[:@ident, "par", [28, 23]]], nil, nil, nil, nil, nil, nil]], [:bodystmt, [[:assign, [:var_field, [:@ident, "pax", [29, 12]]], [:var_ref, [:@ident, "par", [29, 18]]]]], nil, nil, nil]] - def on_defs(args, rest) - on_def(rest[1], rest[2..]) if args[1][1] == 'self' && rest[0][1] == '.' + # Helper to handle nested multi-target nodes (for assignments) + def visit_multi_target(node) + node.lefts.each do |target| + case target + when Prism::LocalVariableTargetNode + add_variable(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::InstanceVariableTargetNode + add_ivar(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::MultiTargetNode + visit_multi_target(target) + end + end end - # Multiple left hand side - # (foo, bar) = somethingg... - def on_mlhs(args, rest) - process(args) - process(rest) + # Helper to extract variables from multi-target nodes (for parameters) + # block_method { |(a, b), c| ... } # we need to extract a and b in addition to c + def extract_multi_target_variables(node) + node.lefts.each do |target| + case target + when Prism::RequiredParameterNode + add_variable(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::MultiTargetNode + # Recursively handle nested destructuring + extract_multi_target_variables(target) + end + end + + # Handle rest in multi-target (though rare in practice) + if node.rest + case node.rest + when Prism::SplatNode + add_variable(node.rest.expression.name.to_s, node.rest.expression.location.start_line, node.rest.expression.location.start_column) if node.rest.expression.is_a?(Prism::RequiredParameterNode) + end + end + + # Handle rights (elements after rest) + node.rights.each do |target| + case target + when Prism::RequiredParameterNode + add_variable(target.name.to_s, target.location.start_line, target.location.start_column) + when Prism::MultiTargetNode + extract_multi_target_variables(target) + end + end end - # ident is something that gets processed at parameters to a function or block - def on_ident(name, ((line, column))) - add_variable(name, line, column) + # Visit call nodes (method calls) + def visit_call_node(node) + name = node.name.to_s + line = node.location.start_line + + # Handle special commands like attr_accessor, private, etc. + # Only handle if there's no receiver (i.e., it's a method call in current scope) + if node.receiver.nil? + block_node = node.block if node.block.is_a?(Prism::BlockNode) + handle_command(name, line, node, block_node) + + # If the command pushed a scope for a block, pop it after visiting children + should_pop = @command_pushed_scope + @command_pushed_scope = false + + super + + pop_scope if should_pop + else + super + end end - def on_params(args, rest) - process(args) - process(rest) + private + + def extract_parameters_data(params_node) + return [] unless params_node + + params = [] + + # Required parameters + params_node.requireds.each do |param| + case param + when Prism::RequiredParameterNode + params << { name: param.name.to_s, type: 'required' } + when Prism::MultiTargetNode + # Handle destructuring - simplified as single param + params << { name: 'args', type: 'required' } + end + end + + # Optional parameters + params_node.optionals.each do |param| + params << { name: param.name.to_s, type: 'optional' } + end + + # Rest parameter + params << { name: "*#{params_node.rest.name}", type: 'rest' } if params_node.rest&.name + + # Keyword parameters + params_node.keywords.each do |param| + name = param.name.to_s + params << { name: "#{name}:", type: 'keyword' } + end + + # Keyword rest parameter + params << { name: "**#{params_node.keyword_rest.name}", type: 'keyword_rest' } if params_node.keyword_rest&.name + + # Block parameter + params << { name: "&#{params_node.block.name}", type: 'block' } if params_node.block&.name + + params end - # The on_command function idea is stolen from RipperTags https://github.com/tmm1/ripper-tags/blob/master/lib/ripper-tags/parser.rb - def on_command(args, rest) - # [:@ident, "public", [6, 8]] - (_, name, (line, _column)) = args + def visit_parameters(params_node) + return unless params_node + + # Required parameters + params_node.requireds.each do |param| + case param + when Prism::RequiredParameterNode + add_variable(param.name.to_s, param.location.start_line, param.location.start_column) + when Prism::MultiTargetNode + # Handle destructuring in parameters like |(a, b), c| + extract_multi_target_variables(param) + end + end - method_name = "on_#{name}_command" - if respond_to? method_name - return send(method_name, line, args, rest) - else - RubyLanguageServer.logger.debug("We don't have a #{method_name} with #{args}") + # Optional parameters + params_node.optionals.each do |param| + add_variable(param.name.to_s, param.location.start_line, param.location.start_column) end - case name - when 'public', 'private', 'protected' - # FIXME: access control... - process(rest) - when 'delegate' - # on_delegate(*args[0][1..-1]) - when 'def_delegator', 'def_instance_delegator' - # on_def_delegator(*args[0][1..-1]) - when 'def_delegators', 'def_instance_delegators' - # on_def_delegators(*args[0][1..-1]) + # Rest parameter + add_variable(params_node.rest.name.to_s, params_node.rest.location.start_line, params_node.rest.location.start_column) if params_node.rest&.name + + # Keyword parameters + params_node.keywords.each do |param| + name = param.name.to_s + add_variable(name, param.location.start_line, param.location.start_column) end + + # Keyword rest parameter + add_variable(params_node.keyword_rest.name.to_s, params_node.keyword_rest.location.start_line, params_node.keyword_rest.location.start_column) if params_node.keyword_rest&.name + + # Block parameter + add_variable(params_node.block.name.to_s, params_node.block.location.start_line, params_node.block.location.start_column) if params_node.block&.name end - # The on_method_add_arg function is downright stolen from RipperTags https://github.com/tmm1/ripper-tags/blob/master/lib/ripper-tags/parser.rb - def on_method_add_arg(call, args) - call_name = call && call[0] - first_arg = args && args[0] == :args && args[1] + def visit_block_parameters(params_node) + return unless params_node - if call_name == :call && first_arg - if args.length == 2 - # augment call if a single argument was used - call = call.dup - call[3] = args[1] - end - call - elsif call_name == :fcall && first_arg - name, line = call[1] - case name - when 'alias_method' # this is an fcall - [:alias, args[1][0], args[2][0], line] if args[1] && args[2] - when 'define_method' # this is an fcall - [:def, args[1][0], line] - when 'public_class_method', 'private_class_method', 'private', 'public', 'protected' - access = name.sub('_class_method', '') - - if args[1][1] == 'self' - klass = 'self' - method_name = args[1][2] + visit_parameters(params_node.parameters) if params_node.parameters + end + + def constant_path_name(node) + case node + when Prism::ConstantReadNode + node.name.to_s + when Prism::ConstantPathNode + # For Some::Class, we need to recursively build the path + parts = [] + + # Add the rightmost name (e.g., "Class" in Some::Class) + parts << node.name.to_s + + # Traverse the parent chain + current = node.parent + while current + case current + when Prism::ConstantReadNode + parts.unshift(current.name.to_s) + current = nil + when Prism::ConstantPathNode + parts.unshift(current.name.to_s) + current = current.parent else - klass = nil - method_name = args[1][1] + current = nil end - - [:def_with_access, klass, method_name, access, line] end + + parts.join('::') + else + node.to_s if node.respond_to?(:to_s) end end - private + def handle_command(name, line, node, block_node = nil) + method_name = "on_#{name}_command" + if respond_to?(method_name) + # Extract arguments from Prism node and format for command handler + args = extract_command_args(node) + rest = extract_command_rest(node) + @current_block_node = block_node + send(method_name, line, args, rest) + @current_block_node = nil + else + RubyLanguageServer.logger.debug("We don't have a #{method_name}") + end + end + + # Extract arguments in format expected by command handlers + # Returns: [:@ident, "method_name", [line, column]] + def extract_command_args(node) + [:@ident, node.name.to_s, [node.location.start_line, node.location.start_column]] + end + + # Extract the rest/body arguments from a call node + # For attr methods, this needs to extract symbol arguments + # For rake tasks, this needs to extract keyword hash keys + # For rspec commands, this needs to extract constant paths + def extract_command_rest(node) + return [] unless node.arguments + + args = [] + node.arguments.arguments.each do |arg| + case arg + when Prism::SymbolNode + # Extract symbol name + args << arg.value.to_s if arg.value + when Prism::StringNode + args << arg.unescaped + when Prism::ConstantReadNode + # Handle constant arguments like: describe SomeClass + args << arg.name.to_s + when Prism::ConstantPathNode + # Handle constant path arguments like: describe Some::Class + args << constant_path_name(arg) + when Prism::KeywordHashNode + # Handle keyword arguments like: task something: [] do + # Extract the keys which are the task names + arg.elements.each do |element| + if element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) + # Add the colon suffix to match Rake syntax expectations + args << "#{element.key.value}:" + end + end + end + end + args + end def add_variable(name, line, column, scope = @current_scope) return if @shallow + return if scope.nil? - newvar = scope.variables.where(name:).first_or_create!( + scope.variables.where(name:).first_or_create!( line:, column:, code_file: scope.code_file ) - if scope.top_line.blank? - scope.top_line = line - scope.save! - end - newvar end def add_ivar(name, line, column) scope = @current_scope + return if scope.nil? + unless scope == root_scope ivar_scope_types = [ScopeData::Base::TYPE_CLASS, ScopeData::Base::TYPE_MODULE] while !ivar_scope_types.include?(scope.class_type) && !scope.parent.nil? @@ -259,35 +437,19 @@ def add_ivar(name, line, column) add_variable(name, line, column, scope) end - def add_scope(args, rest, type) - (_, name, (line, column)) = args - scope = push_scope(type, name, line, column) - if type == ScopeData::Scope::TYPE_METHOD && @shallow - process([]) - else - process(rest) - end - pop_scope - scope - end - def type_is_class_or_module(type) [RubyLanguageServer::ScopeData::Base::TYPE_CLASS, RubyLanguageServer::ScopeData::Base::TYPE_MODULE].include?(type) end - def push_scope(type, name, top_line, column, close_siblings = true) - close_sibling_scopes(top_line) if close_siblings - new_scope = ScopeData::Scope.build(@current_scope, type, name, top_line, column) - new_scope.bottom_line = @lines - new_scope.save! + def push_scope(type, name, top_line, column, end_line, close_siblings = true) + close_sibling_scopes if close_siblings + new_scope = ScopeData::Scope.build(@current_scope, type, name, top_line, column, end_line) @current_scope = new_scope end - # This is a very poor man's "end" handler because there is no end handler. - # The notion is that when you start the next scope, all the previous peers and unclosed descendents of the previous peer should be closed. - def close_sibling_scopes(line) - parent_scope = @current_scope - parent_scope&.descendants&.each { |scope| scope.close(line) } + # Clean up any empty block scopes when starting a new sibling scope. + def close_sibling_scopes + @current_scope&.descendants&.each(&:close) end def pop_scope @@ -295,21 +457,26 @@ def pop_scope end end - # This class builds on Ripper's sexp processor to add ruby and rails magic. + # This class builds on Prism's AST processor to add ruby and rails magic. # Specifically it knows about things like alias, attr_*, has_one/many, etc. # It adds the appropriate definitions for those magic words. - class ScopeParser < Ripper + class ScopeParser attr_reader :root_scope def initialize(text, shallow = false) text ||= '' # empty is the same as nil - but it doesn't crash begin - sexp = self.class.sexp(text) - rescue TypeError => e - RubyLanguageServer.logger.error("Exception in sexp: #{e} for text: #{text}") + result = Prism.parse(text) + processor = PrismProcessor.new(text.split("\n").length, shallow) + processor.root_scope # Initialize root scope + result.value.accept(processor) + @root_scope = processor.root_scope + rescue StandardError => e + RubyLanguageServer.logger.error("Exception in prism parsing: #{e} for text: #{text}") + # Create an empty root scope on error + processor = PrismProcessor.new(text.split("\n").length, shallow) + @root_scope = processor.root_scope end - processor = SEXPProcessor.new(sexp, text.split("\n").length, shallow) - @root_scope = processor.root_scope end end end diff --git a/lib/ruby_language_server/scope_parser_commands/rails_commands.rb b/lib/ruby_language_server/scope_parser_commands/rails_commands.rb index ba66aeb..5606ae7 100644 --- a/lib/ruby_language_server/scope_parser_commands/rails_commands.rb +++ b/lib/ruby_language_server/scope_parser_commands/rails_commands.rb @@ -11,8 +11,7 @@ def rails_add_reference(line, args, rest) (_, _, (_, column)) = args name = rest.flatten.detect { |o| o.instance_of? String } [name, "#{name}="].each do |method_name| - push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, method_name, line, column) - process(rest) + push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, method_name, line, column, line) pop_scope end end diff --git a/lib/ruby_language_server/scope_parser_commands/rake_commands.rb b/lib/ruby_language_server/scope_parser_commands/rake_commands.rb index b1fe9b1..5e2590f 100644 --- a/lib/ruby_language_server/scope_parser_commands/rake_commands.rb +++ b/lib/ruby_language_server/scope_parser_commands/rake_commands.rb @@ -3,28 +3,26 @@ module RubyLanguageServer module ScopeParserCommands module RakeCommands - def on_task_command(line, args, rest) + def on_task_command(line, _args, rest) # OMG. Rake commands can have like any form. # The most reliable way I can see to name them is to grab the string # I *so* do not want to hear about it when it doesn't work. name = rest.flatten.detect { |o| o.instance_of?(String) } - # add_scope(args, rest, ScopeData::Scope::TYPE_METHOD) - push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, false) - process(args) - process(rest) - # We push a scope and don't pop it because we're called inside on_method_add_block + # Push the named scope - the block node will create a child "block" scope + end_line = @current_block_node ? @current_block_node.location.end_line : line + push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, end_line, false) + @command_pushed_scope = true end - def on_namespace_command(line, args, rest) + def on_namespace_command(line, _args, rest) # OMG. Rake commands can have like any form. # The most reliable way I can see to name them is to grab the string # I *so* do not want to hear about it when it doesn't work. name = rest.flatten.detect { |o| o.instance_of?(String) } - # add_scope(args, rest, ScopeData::Scope::TYPE_METHOD) - push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, false) - process(args) - process(rest) - # We push a scope and don't pop it because we're called inside on_method_add_block + # Push the named scope - the block node will create a child "block" scope + end_line = @current_block_node ? @current_block_node.location.end_line : line + push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, end_line, false) + @command_pushed_scope = true end end end diff --git a/lib/ruby_language_server/scope_parser_commands/rspec_commands.rb b/lib/ruby_language_server/scope_parser_commands/rspec_commands.rb index fc312b2..e4123cd 100644 --- a/lib/ruby_language_server/scope_parser_commands/rspec_commands.rb +++ b/lib/ruby_language_server/scope_parser_commands/rspec_commands.rb @@ -17,13 +17,16 @@ def on_it_command(line, args, rest) private - def rspec_block_command(prefix, line, args, rest) + def rspec_block_command(prefix, line, _args, rest) name = "#{prefix} " name += rest.flatten.select { |part| part.instance_of?(String) }.join('::') - push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, false) - process(args) - process(rest) - # We push a scope and don't pop it because we're called inside on_method_add_block + + # Push the named scope (e.g., "describe Something") + # The block node will create a child "block" scope automatically + # Signal that visit_call_node should pop this scope after visiting children + end_line = @current_block_node ? @current_block_node.location.end_line : line + push_scope(ScopeData::Scope::TYPE_MODULE, name, line, 0, end_line, false) + @command_pushed_scope = true end end end diff --git a/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb b/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb index 291eca3..70b7944 100644 --- a/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb +++ b/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb @@ -55,11 +55,11 @@ def ruby_command_column(args) def ruby_command_add_attr(line, column, names, reader, writer) names.each do |name| if reader - push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, name, line, column) + push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, name, line, column, line) pop_scope end if writer - push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, "#{name}=", line, column) + push_scope(RubyLanguageServer::ScopeData::Base::TYPE_METHOD, "#{name}=", line, column, line) pop_scope end end diff --git a/ruby_language_server.gemspec b/ruby_language_server.gemspec index 8a55973..820e875 100644 --- a/ruby_language_server.gemspec +++ b/ruby_language_server.gemspec @@ -42,15 +42,17 @@ Gem::Specification.new do |spec| spec.add_dependency 'fuzzy_match' # completion matching spec.add_dependency 'json' spec.add_dependency 'ostruct' + spec.add_dependency 'prism' spec.add_dependency 'sqlite3' spec.add_development_dependency 'debug' - spec.add_development_dependency 'guard' + spec.add_development_dependency 'guard' # as of ruby 4: Sorry, you can't use Pry without Readline or a compatible library. spec.add_development_dependency 'guard-minitest' spec.add_development_dependency 'guard-rubocop' spec.add_development_dependency 'minitest' spec.add_development_dependency 'minitest-reporters' spec.add_development_dependency 'rake' # required by guard :-( + spec.add_development_dependency 'rb-readline' spec.add_development_dependency 'rubocop', '>1.38.0' # Something broke in 1.38.0. Move to rubocop --server? spec.add_development_dependency 'rubocop-ast', '>1.32.0' # Something broke in 1.38.0. Move to rubocop --server? spec.add_development_dependency 'rubocop-minitest' diff --git a/spec/lib/ruby_language_server/completion_spec.rb b/spec/lib/ruby_language_server/completion_spec.rb index 860586a..31addaf 100644 --- a/spec/lib/ruby_language_server/completion_spec.rb +++ b/spec/lib/ruby_language_server/completion_spec.rb @@ -74,7 +74,8 @@ def scope_completions_in_target_context(*args) context_scope = nar_naz_scope position_scopes = @scope_parser.root_scope.self_and_descendants.for_line(context_scope.top_line + 1) completions = scope_completions_in_target_context(context, context_scope, position_scopes) - assert_equal(["Bar", "baz", "@biz", "@bottom", "bar", "Nar", "naz", "bogus"], completions.map(&:first)) + # Sort for consistent comparison since order may vary + assert_equal(["@biz", "@bottom", "Bar", "Nar", "bar", "baz", "bogus", "naz"], completions.map(&:first).sort) end end @@ -87,4 +88,75 @@ def scope_completions_in_target_context(*args) assert_equal([], completions.map(&:first)) end end + + describe 'method parameters' do + before do + @code_with_params = <<-SOURCE + class Foo + def simple_method(arg1, arg2) + end + + def method_with_keyword(name:, age: 18) + end + + def method_mixed(required, optional = nil, *rest, keyword:, **kwargs, &block) + end + end + SOURCE + @parser = RubyLanguageServer::ScopeParser.new(@code_with_params) + end + + it 'should capture method parameters' do + simple_method = @parser.root_scope.self_and_descendants.find_by_path('Foo#simple_method') + params = simple_method.parsed_parameters + assert_equal(2, params.length) + assert_equal('arg1', params[0]['name']) + assert_equal('required', params[0]['type']) + assert_equal('arg2', params[1]['name']) + assert_equal('required', params[1]['type']) + end + + it 'should capture keyword parameters' do + keyword_method = @parser.root_scope.self_and_descendants.find_by_path('Foo#method_with_keyword') + params = keyword_method.parsed_parameters + assert_equal(2, params.length) + assert_equal('name:', params[0]['name']) + assert_equal('keyword', params[0]['type']) + assert_equal('age:', params[1]['name']) + assert_equal('keyword', params[1]['type']) + end + + it 'should generate snippet for method with parameters' do + snippet = RubyLanguageServer::Completion.send(:generate_method_snippet, 'simple_method', [ + { 'name' => 'arg1', 'type' => 'required' }, + { 'name' => 'arg2', 'type' => 'required' } + ]) + assert_equal('simple_method(${1:arg1}, ${2:arg2})', snippet) + end + + it 'should generate snippet for method with keyword parameters' do + snippet = RubyLanguageServer::Completion.send(:generate_method_snippet, 'method_with_keyword', [ + { 'name' => 'name:', 'type' => 'keyword' }, + { 'name' => 'age:', 'type' => 'keyword' } + ]) + assert_equal('method_with_keyword(name: ${1:value}, age: ${2:value})', snippet) + end + + it 'should store parameters and generate snippets for methods' do + all_scopes = @parser.root_scope.self_and_descendants + simple_method_scope = all_scopes.find_by_path('Foo#simple_method') + + # Test 1: Ensure method has parameters stored + assert simple_method_scope, 'simple_method scope should exist' + params = simple_method_scope.parsed_parameters + assert params.any?, 'simple_method should have parameters' + assert_equal(2, params.length) + assert_equal('arg1', params[0]['name']) + assert_equal('arg2', params[1]['name']) + + # Test 2: Verify snippet generation works with those parameters + snippet = RubyLanguageServer::Completion.send(:generate_method_snippet, 'simple_method', params) + assert_equal('simple_method(${1:arg1}, ${2:arg2})', snippet) + end + end end diff --git a/spec/lib/ruby_language_server/project_manager_spec.rb b/spec/lib/ruby_language_server/project_manager_spec.rb index 731f491..4671e2e 100644 --- a/spec/lib/ruby_language_server/project_manager_spec.rb +++ b/spec/lib/ruby_language_server/project_manager_spec.rb @@ -91,7 +91,10 @@ class Foo < ActiveRecord::Base assert_equal({isIncomplete: true, items: [{label: 'Foo', kind: 7}]}, results) position = OpenStruct.new(line: 6, character: 4) results = project_manager.completion_at('search_uri', position) - assert_equal({isIncomplete: true, items: [{label: 'Bar', kind: 9}, {label: 'bar', kind: 2}, {label: '@baz', kind: 7}, {label: 'bar=', kind: 2}]}, results) + # Sort items by label for consistent comparison + results[:items] = results[:items].sort_by { |item| item[:label] } + expected = {isIncomplete: true, items: [{label: '@baz', kind: 7}, {label: 'Bar', kind: 9}, {label: 'bar', kind: 2}, {label: 'bar=', kind: 2}]} + assert_equal(expected, results) end end diff --git a/spec/lib/ruby_language_server/scope_parser_commands/rspec_commands_spec.rb b/spec/lib/ruby_language_server/scope_parser_commands/rspec_commands_spec.rb index afc52c4..3f74ca2 100644 --- a/spec/lib/ruby_language_server/scope_parser_commands/rspec_commands_spec.rb +++ b/spec/lib/ruby_language_server/scope_parser_commands/rspec_commands_spec.rb @@ -35,7 +35,7 @@ describe 'blocks' do it 'should have a few' do assert_equal('describe Some::Class', @parser.root_scope.children.first.name) - assert_equal(['', 'describe Some::Class', 'block', 'describe some thing', 'block', 'describe inner thing', 'block', 'it is happy', 'block', 'it is not sad', 'block', 'context some context', 'block'], @parser.root_scope.self_and_descendants.filter_map(&:name)) + assert_equal(['', 'describe Some::Class', 'block', 'block', 'describe some thing', 'block', 'block', 'describe inner thing', 'block', 'it is happy', 'block', 'it is not sad', 'block', 'context some context', 'block'], @parser.root_scope.self_and_descendants.filter_map(&:name)) end it 'should have a context block' do diff --git a/spec/lib/ruby_language_server/scope_parser_commands/ruby_commands_spec.rb b/spec/lib/ruby_language_server/scope_parser_commands/ruby_commands_spec.rb index d2d413a..b03f07a 100644 --- a/spec/lib/ruby_language_server/scope_parser_commands/ruby_commands_spec.rb +++ b/spec/lib/ruby_language_server/scope_parser_commands/ruby_commands_spec.rb @@ -24,14 +24,14 @@ class ModelClass describe 'attr_reader' do it 'should have appropriate functions' do # class_scope = @parser.root_scope.children.first - assert_equal(['something_else', 'something_else2', 'read_write', 'read_write='], class_scope.children.map(&:name)) + assert_equal(['something_else', 'something_else2', 'read_write', 'read_write=', 'block'], class_scope.children.map(&:name)) end end describe 'attr' do it 'should have appropriate functions' do # class_scope = @parser.root_scope.children.first - assert_equal(['something_else', 'something_else2', 'read_write', 'read_write='], class_scope.children.map(&:name)) + assert_equal(['something_else', 'something_else2', 'read_write', 'read_write=', 'block'], class_scope.children.map(&:name)) end end end