sexp_cli_tools
Educational project exploring the utility in searching and manipulating codebases using S-expressions.
Inspiration
I once found wide spread use of a "magical" Ruby method which was unnecessary. The intent of this method was to relieve the developer from the repetition of setting instance variables from method parameters. How this magic method did this was difficult to understand for most. Upon examining this method, I noticed it made costly calls to do at run time what could have been done by a developer with a keyboard, or if you wanted to, with code at load time.
I was new to the team and project, and because the use of this method was wide spread, I wanted a systematic and repeatable approach to refactoring it out of existence so that I and my new colleagues could trust the widespread change.
Concrete Examples We Can All Learn From
Decoupling Subclasses Using Hook Messages
In Chapter 6 of Practical Object-Oriented Design in Ruby by Sandi Metz, part of the discussion is focused on finding the ideal coupling between child and parent classes. One proposal introduced in that chapter is to use hook methods, instead of calling super.
Lets imagine a scenario where we have achieved total consensus in an organization, and the new direction is to dogmatically use hook methods, instead of calling super.
Goal
- Replace methods that call super with a hook method
- Modify parent class' implementation of the supered method to call hook methods
Initial state
We will begin with the classes Bicycle
, RoadBike
and MountainBike
. We will build them up to the state from Managing Coupling Between Superclasses and Subclasses until we can recognize the important parts of the "discernible pattern."
Milestones
Things we must be able to interogate about this code:
- Which are the children, and which is the parent class?
- Which methods call super, and which is the method that responds to super?
- What in each method that calls super needs to be in the respective hook method?
- What change needs to occur in the method responding to super to leverage the hook methods?
Finding the parent of child classes
An s-expression for an empty class in Ruby, as parsed by ruby_parser
, looks like this:
class Bicycle
end
s(:class, :Bicycle, nil)
An s-expression has a type and a body. The above s-expression's type is :class
and the body is s(:Bicycle, nil)
.
An s-expression for an empty class with a parent looks like this:
class MountainBike < Bicycle
end
s(:class, :MountainBike, s(:const, Bicycle))
This s-expression's type is still :class
, but the body
is: s(:MountainBike, s(:const, :Bicycle))
.
An s-expression is a representation of the abstract syntax tree, and the s-expressions generated by ruby_parser
use this sexp_body
recursion to create that tree.
Matching a class
ruby_parser
comes with a class Sexp::Matcher
which provides a terse syntax that we can use to select nodes from the s-expression tree.
The Sexp::Matcher
expression that matches any class definition is: (class ___)
. That expression uses the triple underscore ___
wildcard to match anything following a class
type s-expression.
Matching a class with an explicit parent
The Sexp::Matcher
expression that matches any class with an explicit parent is: (class _ (const _) ___)
. This uses the single underscore _
positional wild card match, and then matches the constant s-expression containing the parent class.
Matching a class with an implicit parent
It is also possible to include negation in Sexp::Matcher
. A class with an implicit parent does not have the constant s-expression (const _)
. Right now, our class s-expression matcher, (class ___)
matches all our classes. To match only Bicycle
we must use negation. That s-expression is (class _ [not? (const _)] ___)
.
Capturing what we've learned in a tool that people can use
Knowing the syntax for Sexp::Matcher
expressions gives us some confidence that we can start iterating on a tool to help us achieve our goal. The implicit expectation in the project name is that a command line interface is provided. To complete an initial release of a command line tool, we'll use the rubygem aruba
to help with test setup and teardown.
The sexp
command offers a convenient shortcut to the Sexp::Matcher
expressions we'll develop. As we figure out the s-expression matchers along the way, we can add to the list of known matchers to create simple shortcuts, like with the builtin sexp find child-class
or sexp find parent-class
.
- Checkout the tests for examples of how to test drive your own.
- Checkout the implementation to see how easy it is to add one.
Methods that call super, and methods that are super
Iterating on figuring out Sexp::Matcher
patterns
What isn't shown in the commit which added the Sexp::Matcher
is the trial and error in the console trying to remember the terse rules.
Setting up a unit test can help close that iteration loop. Consider the unit test for SexpCliTools::Matchers::SuperCaller
Allowing users to experiment with s-expressions might enable exploration and discovery. The sexp find
command also supports inline s-expressions. Try these in your projects:
sexp find '(class ___)'
to find class definitionssexp find '[child (class ___)]'
to find class definitions nested in a namespacesexp find '[child (case ___)]'
to find case statements
So having test driven the development of the super-caller
matcher next we have to find the methods that respond to super
.
Finding super implementations
So far we've been using Sexp::Matcher
strings to find quite abstract parts of our code. But, it's completely possible to fill in what in the parts we know that we'd like to find.
sexp find '(class :Bicycle ___)'
from my working copy of this project turns up the test fixture file forBicycle
, as well as the copy of itaruba
makes in thetmp/
directory for testing purposes.sexp find '[child (defn :initialize ___)]'
only turns up the test fixture file forRoadBike
. I guess it is time to fill in more of ourBicycle
class!
Finding the super implementation will involve finding a class that contains a method defintion. So far, our matchers haven't taken any parameters. A (naive) matcher for a super implementation might have two parameters, the name of the class we expect to define the method, and the name of the method.
Passing matcher parameters
Early on I chose to have the second sequence argument to the command line interface sexp find
the glob pattern of files to include in the search. However, I want to prioritize matcher parameters for that position now. Although my test coverage didn't include tests for that glob pattern, I did document it.
So, when I moved that out into the --include
command line option, that was a breaking change to the public interface. That would necessitate incrementing the major version number according to semantic versioning. I have a hunch that because I'm still in the 0
major release, I could get away with not bumping it. But, I think the --include
is something I can stick to.
What I remember about semantic versioning is that additions can just be minor version bumps. So, as long as I don't make a backwards incompatible change to the find
command or the --include
option I should be good.
Following merge of: ✨ sexp find method-implementation passed_method
lists files that define the passed method I'll release v1.0.0
! In that PR I chose to do inside-out testing because the aruba
tests are a bit slow.
I found it helpful to run just the CLI command tests I was working on using the TEST
and TESTOPTS
options to the rake
test tast, like so:
rake TEST='test/sexp_cli_tools/cli_test.rb' TESTOPTS="--name=/method-implementation/"