Skip to content

Commit 6ceef33

Browse files
authored
Improve ruby extractor (#33)
* Converting Ruby extractor to tree sitter * Updating docs post Ruby refactoring
1 parent 7a010e2 commit 6ceef33

9 files changed

Lines changed: 1341 additions & 1282 deletions

File tree

ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Two design choices make this graph useful in a way that "throw the repo at an LL
1818

1919
2. **Every fact is derived, never inferred.** This is the core invariant:
2020

21-
> **Nothing in the graph is guessed by a language model.** Every node and edge comes from a real parser (Go's `go/ast`, a tree-sitter grammar, a language-specific scanner) or a deterministic algorithm (Tarjan's strongly-connected-components for cycles, pattern matching for layers). Run enola twice on the same commit and you get the same graph, byte for byte.
21+
> **Nothing in the graph is guessed by a language model.** Every node and edge comes from a real parser (Go's `go/ast`, a tree-sitter grammar, a YAML/JSON scanner) or a deterministic algorithm (Tarjan's strongly-connected-components for cycles, pattern matching for layers). Run enola twice on the same commit and you get the same graph, byte for byte.
2222
2323
That determinism is the whole point. An AI agent reasoning over enola's graph is standing on ground truth it can trust, instead of re-deriving the structure of your code — imperfectly, and at the cost of tokens — on every single task.
2424

@@ -343,7 +343,7 @@ Each extractor is detected by characteristic project files and then parses what
343343
| Python | tree-sitter | `pyproject.toml`, `setup.py`, `requirements.txt`, `Pipfile`, `pytest.ini`, `mypy.ini`, `tox.ini`, or `setup.cfg` (root or up to 3 levels deep) |
344344
| TypeScript | tree-sitter | `tsconfig.json`, `tsconfig.base.json`, or `package.json` with TypeScript (root or one level deep) |
345345
| Swift | tree-sitter | `Package.swift`, `.xcodeproj`, or `.xcworkspace` present |
346-
| Ruby | regex scanner | `Gemfile` present |
346+
| Ruby | tree-sitter | `Gemfile` present |
347347
| C++ | tree-sitter | a C++ source (`.cpp`/`.cc`/`.cxx`/`.hpp`/...) present, or a build file (`CMakeLists.txt`/`Makefile`/`meson.build`/`*.vcxproj`) plus any header |
348348
| OpenAPI | YAML/JSON scanner| any file containing `openapi:` or `swagger:` |
349349

@@ -361,7 +361,7 @@ Each extractor is detected by characteristic project files and then parses what
361361

362362
**OpenAPI** scans for spec files independently of the main walker (so it finds them even when `*.yaml`/`*.json` are globally ignored), confirming candidates by an `openapi:`/`swagger:` key. It emits one `route` per operation enriched with method, `operationId`, summary, tags, and a spec back-reference; specs under an `openapi/client/` directory are marked `role:"client"`. Gateway extensions (`x-gateway-config`, `x-gateway-capabilities`) are parsed into props.
363363

364-
**Ruby** is Rails-aware: ActiveRecord models (`has_many`, `belongs_to`, scopes, table inference), the route DSL in `config/routes.rb` (resources, namespaces, member/collection blocks), and Packwerk package boundaries (`package.yml` dependency enforcement). It also tracks modules, classes, methods with visibility, mixins (`include`/`extend`/`prepend`), `ActiveSupport::Concern`, constants, and attributes.
364+
**Ruby** is parsed with tree-sitter, replacing the former line-based regex scanner — the grammar handles heredocs, endless methods (`def x = expr`), multi-line expressions, and the nested scopes that tripped up the line scanner. It is Rails-aware: ActiveRecord models (`has_many`/`has_one`/`belongs_to`/`has_and_belongs_to_many`, scopes, table inference, explicit `self.table_name`) emit `storage` facts; the route DSL in `config/routes.rb` (plus `config/routes/*.rb` and packwerk `draw`) is walked from the real block structure, so nested `namespace`/`scope`/`resources`/`member`/`collection` blocks produce one `route` per RESTful action (honoring `only:`/`except:`); and Packwerk package boundaries (`package.yml` dependency enforcement, `app/public/` privacy) are parsed. It tracks modules, classes, methods with `public`/`private`/`protected` visibility, `class << self` eigenclass and `module_function` methods — now correctly typed as class methods rather than instance methods — mixins (`include`/`extend`/`prepend` → `implements` edges), `ActiveSupport::Concern` (flagged `concern: true`), constants, and `attr_*` accessors. Like the other AST extractors, it walks method bodies for call sites, emitting `calls` edges (qualified `Const.method`/`Ns::Class.method` and receiver `var.method`, deduplicated) and `implements` edges for superclasses — so Ruby participates in `traverse`, `find_path`, and `impact_analysis`.
365365

366366
**C++** is parsed with tree-sitter and handles the header/source split that defines the language. Classes, structs, unions, enums (incl. `enum class`), namespaces, free functions and methods, data members, and `typedef`/`using` aliases become symbol facts named `<dir>.<ns1::ns2::Class::member>` — enola's `<dir>.` module convention on the outside, native C++ `::` scope inside. Because an out-of-line definition `Class::method` (parsed from a `qualified_identifier`) yields the same canonical name as its in-class declaration, a dedup pass **merges a header's method prototype with its `.cpp` definition** into a single symbol (the definition wins for file/line and carries the call-graph edges). Base classes become `implements` edges; method bodies are walked for `calls`/`instantiates` edges; quoted `#include "x.h"` becomes a `dependency` resolved to the declaring module, while system `<...>` includes are skipped. Templates are unwrapped to their inner declaration and flagged `templated`, and the walker descends through `#if`/`#ifdef` preprocessor guards (so code wrapped in `#if defined(HAVE_*)` and headers behind include guards are still extracted). *Limitation:* header/source merging relies on the `.h` and `.cpp` living in the same directory (the common layout); split `include/` + `src/` trees are not merged.
367367

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Working across several repos? Generate the first, then add the rest with append
164164

165165
Framework- and platform-specific detection for each language is described in **[ARCHITECTURE.md → Supported languages](ARCHITECTURE.md#supported-languages)**.
166166

167-
> Python is parsed with tree-sitter and now contributes call and dependency edges to the graph, so `traverse`, `find_path`, and `impact_analysis` reach into Python code — not just modules and routes.
167+
> Python and Ruby are parsed with tree-sitter and contribute call and dependency edges to the graph, so `traverse`, `find_path`, and `impact_analysis` reach into them — not just modules and routes.
168168
169169
---
170170

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148
1010
github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495
1111
github.com/tree-sitter/tree-sitter-python v0.23.6
12+
github.com/tree-sitter/tree-sitter-ruby v0.21.1-0.20240818211811-7dbc1e2d0e2d
1213
github.com/tree-sitter/tree-sitter-typescript v0.23.2
1314
gopkg.in/yaml.v3 v3.0.1
1415
)

internal/extractors/rubyextractor/routes.go

Lines changed: 11 additions & 231 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package rubyextractor
22

33
import (
4-
"bufio"
54
"log"
65
"os"
76
"path/filepath"
@@ -11,20 +10,8 @@ import (
1110
"github.com/enola-labs/enola/internal/facts"
1211
)
1312

14-
// Route DSL regex patterns.
15-
var (
16-
httpVerbRe = regexp.MustCompile(`^\s*(get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:\s*,\s*to:\s*['"]([^'"]+)['"])?`)
17-
resourcesRe = regexp.MustCompile(`^\s*resources?\s+:(\w+)`)
18-
namespaceRe = regexp.MustCompile(`^\s*namespace\s+:(\w+)`)
19-
scopePathRe = regexp.MustCompile(`^\s*scope\s+['"]([^'"]+)['"]`)
20-
scopeModRe = regexp.MustCompile(`^\s*scope\s+module:\s*[:'"](\w+)`)
21-
rootRe = regexp.MustCompile(`^\s*root\s+(?:to:\s*)?['"]([^'"]+)['"]`)
22-
drawRe = regexp.MustCompile(`^\s*draw\s*\(\s*:(\w+)\s*\)`)
23-
memberRe = regexp.MustCompile(`^\s*(member|collection)\s+do\b`)
24-
doBlockRe = regexp.MustCompile(`\bdo\s*(?:\|[^|]*\|)?\s*$`)
25-
onlyRe = regexp.MustCompile(`only:\s*\[([^\]]*)\]`)
26-
exceptRe = regexp.MustCompile(`except:\s*\[([^\]]*)\]`)
27-
)
13+
// symbolListRe extracts symbol names from a string like ":index, :show, :create".
14+
var symbolListRe = regexp.MustCompile(`:(\w+)`)
2815

2916
// extractAllRoutes finds and parses all Rails route files in the repository.
3017
func extractAllRoutes(repoPath string, files []string) []facts.Fact {
@@ -43,14 +30,12 @@ func extractAllRoutes(repoPath string, files []string) []facts.Fact {
4330

4431
for _, relFile := range routeFiles {
4532
absFile := filepath.Join(repoPath, relFile)
46-
f, err := os.Open(absFile)
33+
src, err := os.ReadFile(absFile)
4734
if err != nil {
4835
log.Printf("[ruby-extractor] error reading route file %s: %v", relFile, err)
4936
continue
5037
}
51-
routeFacts := parseRouteFile(f, relFile)
52-
f.Close()
53-
allFacts = append(allFacts, routeFacts...)
38+
allFacts = append(allFacts, parseRouteFileAST(src, relFile)...)
5439
}
5540

5641
return allFacts
@@ -82,206 +67,6 @@ type routeScope struct {
8267
module string
8368
}
8469

85-
// parseRouteFile parses a single Rails route file.
86-
func parseRouteFile(f *os.File, relFile string) []facts.Fact {
87-
var result []facts.Fact
88-
89-
scanner := bufio.NewScanner(f)
90-
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
91-
92-
var (
93-
lineNum int
94-
scopeStack []routeScope
95-
depth int
96-
currentResource string
97-
)
98-
99-
for scanner.Scan() {
100-
lineNum++
101-
line := scanner.Text()
102-
trimmed := strings.TrimSpace(line)
103-
104-
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
105-
continue
106-
}
107-
108-
// Track end keywords.
109-
if trimmed == "end" {
110-
depth--
111-
if depth < 0 {
112-
depth = 0
113-
}
114-
if depth < len(scopeStack) {
115-
scopeStack = scopeStack[:depth]
116-
}
117-
currentResource = ""
118-
continue
119-
}
120-
121-
prefix := buildPrefix(scopeStack)
122-
123-
// draw(:package_name) -- delegation to packwerk package routes.
124-
if m := drawRe.FindStringSubmatch(line); m != nil {
125-
result = append(result, facts.Fact{
126-
Kind: facts.KindRoute,
127-
Name: prefix + "/" + m[1],
128-
File: relFile,
129-
Line: lineNum,
130-
Props: map[string]any{
131-
"method": "DRAW",
132-
"framework": "rails",
133-
"language": "ruby",
134-
"delegate": m[1],
135-
},
136-
})
137-
continue
138-
}
139-
140-
// Namespace.
141-
if m := namespaceRe.FindStringSubmatch(line); m != nil {
142-
scopeStack = append(scopeStack, routeScope{
143-
pathPrefix: "/" + m[1],
144-
module: m[1],
145-
})
146-
depth++
147-
continue
148-
}
149-
150-
// Scope with path.
151-
if m := scopePathRe.FindStringSubmatch(line); m != nil {
152-
path := m[1]
153-
if !strings.HasPrefix(path, "/") {
154-
path = "/" + path
155-
}
156-
scopeStack = append(scopeStack, routeScope{pathPrefix: path})
157-
if doBlockRe.MatchString(line) {
158-
depth++
159-
}
160-
continue
161-
}
162-
163-
// Scope with module.
164-
if m := scopeModRe.FindStringSubmatch(line); m != nil {
165-
scopeStack = append(scopeStack, routeScope{module: m[1]})
166-
if doBlockRe.MatchString(line) {
167-
depth++
168-
}
169-
continue
170-
}
171-
172-
// Root route.
173-
if m := rootRe.FindStringSubmatch(line); m != nil {
174-
result = append(result, facts.Fact{
175-
Kind: facts.KindRoute,
176-
Name: prefix + "/",
177-
File: relFile,
178-
Line: lineNum,
179-
Props: map[string]any{
180-
"method": "GET",
181-
"framework": "rails",
182-
"language": "ruby",
183-
"handler": m[1],
184-
},
185-
Relations: []facts.Relation{
186-
{Kind: facts.RelDeclares, Target: filepath.Dir(relFile)},
187-
},
188-
})
189-
continue
190-
}
191-
192-
// HTTP verb routes: get '/path', post '/path', etc.
193-
if m := httpVerbRe.FindStringSubmatch(line); m != nil {
194-
method := strings.ToUpper(m[1])
195-
path := m[2]
196-
handler := m[3]
197-
198-
if !strings.HasPrefix(path, "/") {
199-
path = "/" + path
200-
}
201-
fullPath := prefix + path
202-
203-
props := map[string]any{
204-
"method": method,
205-
"framework": "rails",
206-
"language": "ruby",
207-
}
208-
if handler != "" {
209-
props["handler"] = handler
210-
}
211-
212-
result = append(result, facts.Fact{
213-
Kind: facts.KindRoute,
214-
Name: fullPath,
215-
File: relFile,
216-
Line: lineNum,
217-
Props: props,
218-
Relations: []facts.Relation{
219-
{Kind: facts.RelDeclares, Target: filepath.Dir(relFile)},
220-
},
221-
})
222-
continue
223-
}
224-
225-
// resources / resource.
226-
if m := resourcesRe.FindStringSubmatch(line); m != nil {
227-
resourceName := m[1]
228-
currentResource = resourceName
229-
resourcePath := prefix + "/" + resourceName
230-
231-
actions := restfulActions(line)
232-
for _, action := range actions {
233-
method := action.method
234-
path := resourcePath + action.suffix
235-
236-
props := map[string]any{
237-
"method": method,
238-
"framework": "rails",
239-
"language": "ruby",
240-
"resource": resourceName,
241-
"action": action.name,
242-
}
243-
244-
result = append(result, facts.Fact{
245-
Kind: facts.KindRoute,
246-
Name: path,
247-
File: relFile,
248-
Line: lineNum,
249-
Props: props,
250-
Relations: []facts.Relation{
251-
{Kind: facts.RelDeclares, Target: filepath.Dir(relFile)},
252-
},
253-
})
254-
}
255-
256-
// If there's a do block, push resource as a scope.
257-
if doBlockRe.MatchString(line) {
258-
scopeStack = append(scopeStack, routeScope{pathPrefix: "/" + resourceName})
259-
depth++
260-
}
261-
continue
262-
}
263-
264-
// member do / collection do.
265-
if m := memberRe.FindStringSubmatch(line); m != nil {
266-
blockType := m[1]
267-
memberPrefix := ""
268-
if blockType == "member" && currentResource != "" {
269-
memberPrefix = "/:id"
270-
}
271-
scopeStack = append(scopeStack, routeScope{pathPrefix: memberPrefix})
272-
depth++
273-
continue
274-
}
275-
276-
// Track other do blocks for depth.
277-
if doBlockRe.MatchString(line) {
278-
depth++
279-
}
280-
}
281-
282-
return result
283-
}
284-
28570
// buildPrefix constructs the current URL prefix from the scope stack.
28671
func buildPrefix(stack []routeScope) string {
28772
var parts []string
@@ -300,8 +85,9 @@ type restAction struct {
30085
suffix string
30186
}
30287

303-
// restfulActions returns the set of REST actions for a resources declaration.
304-
func restfulActions(line string) []restAction {
88+
// restfulActions returns the set of REST actions for a resources declaration,
89+
// honoring only:/except: filters parsed from the declaration's arguments.
90+
func restfulActions(only, except map[string]bool) []restAction {
30591
all := []restAction{
30692
{name: "index", method: "GET", suffix: ""},
30793
{name: "create", method: "POST", suffix: ""},
@@ -312,18 +98,12 @@ func restfulActions(line string) []restAction {
31298
{name: "destroy", method: "DELETE", suffix: "/:id"},
31399
}
314100

315-
// Check for only: [...] filter.
316-
if m := onlyRe.FindStringSubmatch(line); m != nil {
317-
allowed := parseSymbolList(m[1])
318-
return filterActions(all, allowed, true)
101+
if len(only) > 0 {
102+
return filterActions(all, only, true)
319103
}
320-
321-
// Check for except: [...] filter.
322-
if m := exceptRe.FindStringSubmatch(line); m != nil {
323-
excluded := parseSymbolList(m[1])
324-
return filterActions(all, excluded, false)
104+
if len(except) > 0 {
105+
return filterActions(all, except, false)
325106
}
326-
327107
return all
328108
}
329109

0 commit comments

Comments
 (0)