Ruby language notes

Ruby, Lisp ideas, and Ripper S-expressions

A cleaned-up article from two related threads about Ruby, Lisp influence, Ripper, S-expressions, metaprogramming, and code-as-data habits.

Ruby is not Lisp

Ruby is not Lisp, but Ruby programmers often borrow Lisp-shaped habits: blocks as values, internal DSLs, metaprogramming, and the idea that code can be inspected or transformed as structured data.

The difference is that Lisp makes code-as-data central through S-expressions. Ruby makes it possible, but less uniform. Ruby syntax is richer and more irregular, so the bridge from source text to manipulable structure needs a parser.

That is where Ripper is useful. It exposes Ruby's own parser as a way to inspect source and produce S-expression-like trees.

Ripper

Ripper can tokenize Ruby source and produce syntax trees. The output is not a polished domain model. It is close to parser structure, which means it can be verbose and sometimes awkward.

Still, it is valuable for tools that need to understand Ruby without executing it. Linters, formatters, code explorers, and small analysis scripts can use parser output to avoid brittle string matching.

The important rule is to treat the tree as syntax, not semantics. Ripper can show the shape of code, but it does not know every runtime method call, constant lookup, or metaprogrammed behavior.

Code as data

Once Ruby code is represented as nested arrays or parser nodes, familiar Lisp ideas become easier to see. A method call, literal, block, or assignment has structure. Tools can walk that structure and ask precise questions.

For example, a script can find calls to a deprecated method, inspect block arguments, or identify simple constant references. Doing this at the syntax level is safer than regular expressions because it understands comments, strings, and nesting.

The limitation is that Ruby programs are dynamic. Syntax is a starting point. Runtime behavior may depend on included modules, method_missing, refinements, constants loaded later, or database-backed code paths.

Metaprogramming

Ruby's metaprogramming culture gives it a Lisp-like flavor without Lisp's uniform syntax. Methods can define methods. Classes can be reopened. Blocks can be captured. DSLs can make code read like configuration.

That power is useful when it creates a small language for a real domain: routes, validations, migrations, test declarations, or background jobs. It becomes costly when it hides control flow or makes ordinary navigation unreliable.

Ripper can help audit this boundary by showing what the source actually says before runtime magic begins.

Practical use

The practical lesson is not to write Ruby like Lisp. It is to borrow the habit of treating program structure as something that can be examined.

Use Ripper or a richer parser when a codebase needs mechanical inspection. Keep transformations small and reversible. Prefer syntax-aware checks over text scans when false positives matter.

Ruby remains a language of objects, messages, blocks, and open classes. Lisp's influence is most useful as a discipline: when code becomes data, tools can reason about it more carefully.

Tooling limits

Syntax-aware tools should still be humble. A parser can identify a method call, but it may not know which method will be invoked at runtime. Ruby's openness means static analysis is useful but rarely complete.

That is why small, targeted tools are often better than ambitious whole-program analyzers. A checker that finds one risky pattern accurately can be more valuable than a broad tool that pretends Ruby is less dynamic than it is.

Ripper as a bridge

Ruby is not Lisp, but Ripper makes it possible to look at Ruby code as nested structure. That is enough for experiments with code inspection, simple transforms, or teaching parser concepts.

require "ripper"
require "pp"

source = "total = items.sum { |item| item.price }"
pp Ripper.sexp(source)

The output is not a polished domain model. It is a parser-oriented tree, with nodes for assignment, method calls, blocks, and identifiers. The useful move is to walk only the small shape you care about.

def call_names(node, names = [])
  return names unless node.is_a?(Array)

  names << node.dig(1, 1) if node.first == :method_add_arg
  node.each { |child| call_names(child, names) }
  names
end

That kind of traversal gives a Lisp-flavored way to reason about Ruby syntax while still respecting that Ruby's grammar and runtime semantics are their own thing.

Limits of the tree

A syntax tree can show structure, but it does not give you Ruby's full meaning. Constants may resolve differently depending on nesting, methods can be added dynamically, refinements can change lookup, and metaprogramming can create behavior that is not obvious from one parsed file.

That makes Ripper excellent for local tools: linting a pattern, extracting method names, teaching syntax, or building a small code index. It is much weaker as a complete semantic analyzer. The more a tool claims to know about runtime behavior, the more it needs tests against real Ruby execution.

For blog-post-sized experiments, that limitation is a feature. It keeps the tool honest: instead of claiming to understand Ruby, the script can say exactly which syntactic pattern it recognizes and ignore everything else.

That makes the output useful for narrow code-reading aids, where a missed pattern is acceptable but a false claim about runtime behavior would be misleading.

Sources

  1. Ruby documentation, Ripper. https://docs.ruby-lang.org/en/master/Ripper.html
  2. SICP, Structure and Interpretation of Computer Programs. https://mitpress.mit.edu/9780262510875/structure-and-interpretation-of-computer-programs/
  3. Clojure reference, refs and transactions. https://clojure.org/reference/refs