woman biting a pencil in anger while using a laptop

How a local problem led me to contribute to open-source

There is a moment in the life of very few every programmer when they become fixated on working solely through the terminal. I’m one of them experiencing this moment. I’ve been using Vim motions within VSCode for a couple of years. I’ve attempted to migrate to Neovim several times but without success. I would always get stuck with the LSP setup. However, this time, I absolutely had to make the transition. With my current workstation, simply typing using VSCode is as frustrating as discovering that the game of the year runs at only 10 FPS on your computer. Not recommended.

In this article, I’ll share how a local issue with my Ruby LSP led me to contribute a small piece of code to the RubyGems project. What initially seemed like skill issues a local setup mistake turned into a fun, rewarding, and enriching exploration exercise.

The case

I had configured Ruby LSP for Neovim. However, whenever I opened a Ruby project in the editor, an error message would inevitably appear at the bottom, and nothing seemed to work. "Well, that’s annoying". Several weeks went by before I finally mustered the courage to investigate what was going on. "I must have made a mistake during the setup".

Let’s begin with "where are the LSP error logs stored in this machine…?". After some deep sighs, voilà, cat .local/state/nvim/lsp.log showed me this error:

.../ruby-lsp-0.13.4/lib/ruby_indexer/lib/ruby_indexer/configuration.rb:216:in `block (3 levels) in initial_excluded_gems': undefined method `dependencies' for nil (NoMethodError)

            d.to_spec.dependencies.include?(transitive_dependency)

Wait a minute! This seems to be a problem with the LSP itself. It wasn’t skill issues, hey, I’m saved! mistakes on my end. I do some digging in the repository and find existing issues, and even a couple of pull requests that had been created and accepted to fix other parts of the application. They had encountered the same problem of trying to access to_spec, which returned null.

The solution seemed trivial, just a single guard clause or the use of safe navigation in one line of code. However, there was uncertainty about the root cause of the problem. According to discussions, the method that returned null shouldn’t behave that way. However, it being part of the RubyGems project’s API means it would be necessary to investigate this behavior on their end. The focus of the other PRs was simply to put out the fire and prevent this error from breaking the Ruby LSP’s initialization. It was a temporary solution until the underlying problem could be resolved.

I wanted to open a PR, but I needed to test my solution somehow. I didn’t know how to reproduce the problem, but one of the comments on the discussions I saw suggested editing Ruby LSP’s code locally in an attempt to understand the error better. I think it would have taken me quite some time to figure that out on my own.

So, I opened my editor.

nvim .asdf/installs/ruby/3.3.0/lib/ruby/gems/ruby-lsp-0.13.4/lib/ruby_indexer/lib/ruby_indexer/configuration.rb

I edit one line of code.

next if others.any? { |d| d.to_spec && d.to_spec.dependencies.include?(transitive_dependency) }

I open my Ruby project. The LSP works. I wipe away a tear. I read the CONTRIBUTING.md of ruby-lsp. I open a PR explaining everything. It’s accepted a few days later.

Hey, that was awesome! I spent an evening investigating, made the PR, waited for 3 days, corrected 1 enhancement request for the only line I had changed stumbled and a cool contribution to a nice project has been made. I’m excited.

Going further

In the discussions on Ruby LSP repository, there seemed to be a surprise that the function was returning null.

One of the maintainers had even pointed out the possible line of code where the problem might be occurring. I got curious. ‘Is that really it?’ I went to take a look. ‘Who knows, maybe I’ll stumble again.’

I started by reading the code on Github. I tried to understand the purpose of the class where the problem occurs and the method that returns null itself.

  def to_spec
    matches = to_specs.compact

    active = matches.find(&:activated?)
    return active if active

    unless prerelease?
      # Move prereleases to the end of the list for >= 0 requirements
      pre, matches = matches.partition {|spec| spec.version.prerelease? }
      matches += pre if requirement == Gem::Requirement.default
    end

    matches.first
  end

I spend quite a few minutes. The class appears to be a representation of a dependency defined in the Gemfile. The method seems to return the version of the dependency to be used by the project. After resolving conflicts, all available versions are gathered in priority order (latest to oldest – this is what to_specs.compact returns on the first line), respecting the Gemfile requirements. The to_spec method returns the first of these versions (matches.first – the latest) or the one that is currently active (return active if active).

On the first line of to_spec method, to_specs (plural) cannot be empty because the implementation of the function raises errors in this case.

  def to_specs
    matches = matching_specs true

    if matches.empty?
      specs = Gem::Specification.stubs_for name

      if specs.empty?
        raise Gem::MissingSpecError.new name, requirement
      else
        raise Gem::MissingSpecVersionError.new name, requirement, specs
      end
    end

    matches
  end

This makes sense, there are two possible errors. First, no match exists because there is an unresolvable conflict between Gemfile dependencies. Second, no match exists because the requirement specified in the Gemfile refers to a non-existent gem (or version).

The problem needs to be in the to_spec function. Especially since my dependencies are resolved when running bundle install, and the same Ruby project I encountered the issue with is running and functioning in production. If my analysis is correct, to_spec returning null is certainly a bug. I keep that in mind.

Something else catches my eye. It seems strange that bundle install works while to_spec returns null. This suggests there might be duplicated business logic handling behind these two different behaviors. ‘Is it worth trying to understand this further right now?.’ I don’t think so! I lack much, if not all, of the project context. This is a sort of question that could be raised in an issue or PR.

Reproducing the problem

I knew where the problem was likely located, but I didn’t know exactly how to reproduce the error. "First, I need to isolate the setup that causes the error" – I thought. I came up with the idea to explore my Ruby project’s Gemfile until I could create the smallest possible setup that could trigger the Ruby LSP error.

I edit the local ruby-lsp code again to understand which dependency is causing the issue.

next if others.any? { |d| puts d; d.to_spec.dependencies.include?(transitive_dependency) }

I found it. I put it alone in a Gemfile, and nothing happens—no error is thrown when executing the command ruby-lsp that initializes the language server. The gem that causes the issue works specifically with Rails.

I add Rails to the Gemfile.

source 'https://rubygems.org'

git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.3.0'

gem 'rails', '~> 7.1.2'
gem 'validates_timeliness', '>= 6.0.1'

I run the command to initialize the language server and the error appears.

Analyzing my Gemfile.lock, I realize the following: even though I specified >= 6.0.1 for the dependency that returns null, it was resolved to version 7.0.0.beta2. When I change the requirement to = 6.0.1, installing dependencies with bundle install doesn’t work; there is an unresolvable conflict. The only versions available for validate_timeliness after the one I required are 7.0.0.beta1 and 7.0.0.beta2. The error relates to when a release requirement resolves to a prerelease one (such as a beta or alpha version).

"Cool! Now I need to reproduce this error with an automated test to confirm my suspicion."

I set up the RubyGems project locally and began attempting to reproduce the bug with tests. Initially, the test setup felt alien to me, but after an hour or so and many "go to definitions" thanks to my working LSP, I was finally able to grasp the purpose of the test helpers that had been confusing me.

Eventually, I ended up with this test here:

  def test_to_spec_with_only_prereleases
    a_2_a_2 = util_spec "a", "2.a2"
    install_specs a_2_a_2

    a_dep = dep "a", ">= 1"

    assert_equal a_2_a_2, a_dep.to_spec
  end

Let me explain. I simulate an available gem version using util_spec and install_specs. Then, I create a dependency using dep. My expectation is that my dependency "a", ">= 1" resolves to the only available version of the gem “a”, which is version 2.a2.

The test fails, a_dep.to_spec returns null. Changing the version to 2.0 (a release), the test passes. That’s it. Release requirements that resolve to prereleases trigger the bug.

The solution

This is the root of the problem:

      pre, matches = matches.partition { |spec| spec.version.prerelease? }
      matches += pre if requirement == Gem::Requirement.default

The first line removes prereleases as valid options for the project. If the Gemfile requirement is >=0 (the default Gem::Requirement), prereleases become valid options again, but only when no other options are available (they are appended to the end of the list). I ultimately decided not to dwell on this further, concluding it was not worth the effort. After spending several hours investigating the issue, I felt tired. It was late and "I’ve had enough coding for today."

My solution is simply to add a condition to the second line.

      matches += pre if requirement == Gem::Requirement.default || matches.empty?

I run the entire suite of tests, and a distant one fails unexpectedly. The test description completely contradicts what I’m doing. It states that release requirements cannot resolve to prereleases. I’m momentarily frustrated, but then I recall that my bundle install works fine. "There must be a duplication somewhere". I remember having a Gemfile where a release precisely resolves to a prerelease, so this test seems incorrect. Instead of fixing it, I proceed to open a PR containing only my change and tests (including the failing test), and I explain the deadlock.

The initial response:

Unfortunately, we can’t accept this because it would break current behavior. Bundler must not consider prereleases unless explicitly requested in the gemfile.
(…)
@martinemde

My dreams are thrown away. The following response:

Yes, actually Bundler only prefers stable releases, but will resolve to prereleases if that’s the only possibility.
(…)
@deivid-rodriguez

Hopes renewed (the PR links are at the end of the article, in case you’re interested).

The maintainers discussed whether this is a bug or not, debating which behavior was incorrect, bundle install or the to_spec API. Meanwhile, I prepared my PR in case they decided it was indeed a bug to be fixed. I updated the broken test, created some additional ones, and my work was complete.

One of the maintainers reflects on how the current behavior of to_spec already accounts for resolving releases to prereleases, but only when the dependency requirement is equal to the default (>= 0). Since RubyGems partially addresses this issue, that’s the final evidence that I had indeed found a bug.

After 12 long days since it was opened, the PR is merged! 🎉

Lessons

There are two. The first one is:

I made the right decision in not attempting to comprehend why there was duplication in the knowledge to resolve dependencies. It was partially explained during the PR discussion, but it wasn’t relevant at that moment.

If it was indeed in the project’s interest to remove this duplication from the code, it wasn’t the right time to do so. It was time to elucidate the problem and align behaviors. The complexity of eliminating the duplication can be – and probably is – much larger than fixing the bug.

At the very least, I would have to find all the places that depend on this duplicated concept. I would have to create a single interface to replace the two (or more) that already existed.

With a strong dichotomy between two projects that only live in the same repository (Bundler and RubyGems), I would have to decide where to put the final abstraction to be consumed by everyone, not even knowing if this was desired or even possible.

I didn’t have to do all that. The most important aspect was to elucidate the inconsistency, setting the stage for someone to potentially tackle this task in the future.

On the other hand, I made a mistake who hasn’t done so at some point? in not pondering more about the condition requirement == Gem::Requirement.default. I had all the necessary information to realize that this code was strong evidence that I had indeed found a bug. That would have saved everyone a lot of time.

It’s important to consider that I might be experiencing hindsight bias (our tendency to look back at an unpredictable event and think it was easily predictable), so take that with a pinch of salt. Anyway, it serves as learning.

There is wisdom in abstaining for the sake of those who know better. This does not mean that you are incapable of drawing relevant conclusions about a subject.

The second one:

I felt that the path to solving the problem was immensely facilitated by the various small contributions I encountered along the way.

  • The problem reports;
  • Several reproductions with different Gemfiles;
  • The comment about tinkering with local files to better understand the error;
  • Other open PRs indicating that this behavior was unexpected and should be investigated;
  • Indications that the error persisted even after recent fixes;
  • A precise guess on the line where the problem possibly was;
  • The quick and interested response from the maintainers.

It’s unlikely that we’ll always have the privilege of being the one to tie the last knot of a problem; my solution was greatly paved by all these diverse contributions. However, this was such a rewarding experience that I’ll make sure to take action so that more moments like these happen, even though I’m not the lucky one to have the honor of being the cherry on top.

Embrace serendipity for engaging in open source.

That’s it! Thanks for reading!

In case you’re interested:
See Ruby LSP PR
See RubyGems PR

We want to work with you. Check out our "What We Do" section!