Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/code-graph-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extractor registry lives in
| **Supported** | JSX | `.jsx` | [`jsx-component.jsx`](../src/graph/__tests__/fixtures/jsx-component.jsx) and [`extraction-regression.test.ts`](../src/graph/__tests__/extraction-regression.test.ts) cover components, imports, calls, and construction. |
| **Supported** | Python | `.py` | [`sample.py`](../src/graph/__tests__/fixtures/sample.py), [`extractor-python.test.ts`](../src/graph/__tests__/extractor-python.test.ts), and the [`python-package`](../src/graph/__tests__/fixtures/python-package) integration fixture cover extraction and cross-file package resolution. |
| **Supported** | Rust | `.rs` | [`sample.rs`](../src/graph/__tests__/fixtures/sample.rs) and [`extractor-rust.test.ts`](../src/graph/__tests__/extractor-rust.test.ts) cover structs, traits, enums, modules, functions, methods, generics, imports, calls, implementations, construction, returns, and field types. |
| **Supported** | Ruby | `.rb` | [`sample.rb`](../src/graph/__tests__/fixtures/sample.rb) and [`extractor-ruby.test.ts`](../src/graph/__tests__/extractor-ruby.test.ts) cover modules, classes, instance and singleton methods, constants, superclass inheritance, mixins, construction, `require`/`require_relative`, and calls. Parsed with Prism (`@ruby/prism`), not a tree-sitter grammar — see [Vendored Grammars](extractors.md#vendored-grammars). |
| **Unsupported** | Go and other languages | All other extensions | These names may be reserved in [`src/graph/types.ts`](../src/graph/types.ts), but no grammar or extractor is registered for them. Unsupported files are skipped rather than failing a graph build. |

`src/graph/types.ts` contains a wider future-facing language vocabulary. A name
Expand Down
8 changes: 8 additions & 0 deletions docs/extractors.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ When adding a language, document the grammar source and version.
- **Upstream grammar:** [tree-sitter/tree-sitter-rust](https://github.com/tree-sitter/tree-sitter-rust)
- **Upstream grammar license:** MIT

### Ruby
- **Parser source:** `@ruby/prism` npm package, version `^1.9.0` — not tree-sitter. Prism is Ruby core's own parser (the one CRuby 3.3+ uses), shipped as a WASM build maintained by the Ruby core team; it is more semantically accurate for Ruby than the community-maintained `tree-sitter-ruby` grammar.
- **No vendored `.wasm`:** it ships inside the npm package itself (`node_modules/@ruby/prism/src/prism.wasm`), loaded lazily by `src/graph/extraction/prism-runtime.ts` rather than through `grammars.ts`'s tree-sitter `WASM_GRAMMAR_FILES` map.
- **Package license:** MIT
- **Upstream parser:** [ruby/prism](https://github.com/ruby/prism)
- **Upstream parser license:** MIT
- **Deviation note:** `rubyExtractor.extract()` ignores the `tree: TSTree` parameter and re-parses `source` with Prism directly — Prism's typed AST has no tree-sitter node shape to adapt into. `grammars.ts#parse()` still returns a placeholder `TSTree` for Ruby so `extractFile`'s generic null-check behaves the same across languages.

## Pull request proof

Before opening a pull request, run:
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"prepare": "npm run build"
},
"dependencies": {
"@ruby/prism": "^1.9.0",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"cross-spawn": "^7.0.6",
Expand Down
69 changes: 69 additions & 0 deletions src/graph/__tests__/extractor-ruby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { extractFile, loadGrammars } from "../extraction/index.js";
import type { FileExtraction } from "../extraction/index.js";

const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "sample.rb");

describe("Ruby extractor", () => {
let result: FileExtraction;

beforeAll(async () => {
await loadGrammars(["ruby"]);
const source = readFileSync(FIXTURE, "utf-8");
result = extractFile(FIXTURE, source, "ruby")!;
expect(result).not.toBeNull();
});

const node = (kind: string, name: string) =>
result.nodes.find((n) => n.kind === kind && n.name === name);
const hasEdge = (kind: string, targetName: string) =>
result.edges.some((e) => e.kind === kind && e.targetName === targetName);

it("emits a file node and stamps the language", () => {
expect(result.language).toBe("ruby");
expect(node("file", "sample.rb")).toBeDefined();
});

it("extracts a module and nested classes with containment", () => {
expect(node("module", "Greetings")).toBeDefined();
const greeter = node("class", "Greeter");
expect(greeter).toBeDefined();
expect(greeter!.qualifiedName).toBe("Greetings::Greeter");
});

it("extracts instance and singleton methods, marking singleton defs static", () => {
const greet = node("method", "greet");
expect(greet).toBeDefined();
expect(greet!.isStatic).not.toBe(true);

const defaultMethod = node("method", "default");
expect(defaultMethod).toBeDefined();
expect(defaultMethod!.isStatic).toBe(true);
});

it("extracts a module-scoped constant", () => {
expect(node("constant", "PREFIX")).toBeDefined();
});

it("emits a require edge for require/require_relative", () => {
expect(hasEdge("imports", "json")).toBe(true);
expect(hasEdge("imports", "helpers")).toBe(true);
});

it("emits extends, implements (mixin), instantiates, and calls edges", () => {
expect(hasEdge("extends", "Base")).toBe(true); // Greeter < Base
expect(hasEdge("implements", "Comparable")).toBe(true); // include Comparable
expect(hasEdge("instantiates", "Greeter")).toBe(true); // Greeter.new(...) in .default
expect(hasEdge("calls", "warm_up")).toBe(true); // speak -> warm_up
expect(hasEdge("calls", "format_name")).toBe(true); // greet -> format_name
});

it("degrades safely on a construct with no direct graph representation", () => {
// Blocks (do...end / { ... }) passed to calls aren't modeled as their own
// node kind; the surrounding call is still extracted without throwing.
expect(() => extractFile(FIXTURE, "[1, 2].each { |n| puts n }", "ruby")).not.toThrow();
});
});
28 changes: 28 additions & 0 deletions src/graph/__tests__/fixtures/sample.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require "json"
require_relative "helpers"

module Greetings
PREFIX = "Hello"

class Base
def speak
warm_up
end
end

class Greeter < Base
include Comparable

def initialize(name)
@name = name
end

def self.default
Greeter.new("World")
end

def greet
format_name(@name)
end
end
end
59 changes: 49 additions & 10 deletions src/graph/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@

import { Parser, Language as WasmLanguage } from "web-tree-sitter";
import type { Language } from "../types.js";
import type { TSTree } from "./types.js";
import type { TSNode, TSTree } from "./types.js";
import { grammarWasmPath } from "../assets.js";
import { isPrismLoaded, loadPrismRuntime, parseRubySource } from "./prism-runtime.js";

/**
* Languages 0.7.0 can parse, mapped to the vendored grammar WASM basename.
Expand Down Expand Up @@ -42,6 +43,34 @@ const EXTENSION_MAP: Record<string, Language> = {
".jsx": "jsx",
".py": "python",
".rs": "rust",
".rb": "ruby",
};

/**
* Placeholder root node for the `TSTree` `parse()` must return for Ruby, whose
* grammar-less path (below) never walks it: `rubyExtractor` re-parses `source`
* with Prism directly, since Prism's AST has no tree-sitter shape to adapt.
* `parse()` still returns a real `TSTree` so `extractFile`'s generic
* `if (!tree) return null` gate behaves the same for every language.
*/
const NULL_TS_NODE: TSNode = {
type: "program",
text: "",
startIndex: 0,
endIndex: 0,
startPosition: { row: 0, column: 0 },
endPosition: { row: 0, column: 0 },
parent: null,
childCount: 0,
namedChildCount: 0,
children: [],
namedChildren: [],
previousNamedSibling: null,
nextNamedSibling: null,
child: () => null,
namedChild: () => null,
childForFieldName: () => null,
descendantsOfType: () => [],
};

/** Glob pattern for every extension registered above. */
Expand Down Expand Up @@ -69,14 +98,19 @@ export async function initRuntime(): Promise<void> {
* documented WASM-heap race when grammars load concurrently on Node.
*/
export async function loadGrammars(languages: Language[]): Promise<void> {
await initRuntime();
const toLoad = [...new Set(languages)].filter(
(lang) => lang in WASM_GRAMMAR_FILES && !languageCache.has(lang),
);
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang]!;
const grammar = await WasmLanguage.load(grammarWasmPath(wasmFile));
languageCache.set(lang, grammar);
const unique = [...new Set(languages)];
const treeSitterLangs = unique.filter((lang) => lang in WASM_GRAMMAR_FILES);
if (treeSitterLangs.length > 0) {
await initRuntime();
const toLoad = treeSitterLangs.filter((lang) => !languageCache.has(lang));
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang]!;
const grammar = await WasmLanguage.load(grammarWasmPath(wasmFile));
languageCache.set(lang, grammar);
}
}
if (unique.includes("ruby") && !isPrismLoaded()) {
await loadPrismRuntime();
}
}

Expand Down Expand Up @@ -108,7 +142,7 @@ export function detectLanguage(filePath: string): Language {

/** Every language 0.7.0 ships a grammar for. */
export function supportedLanguages(): Language[] {
return Object.keys(WASM_GRAMMAR_FILES) as Language[];
return [...(Object.keys(WASM_GRAMMAR_FILES) as Language[]), "ruby"];
}

/**
Expand All @@ -120,6 +154,11 @@ export function supportedLanguages(): Language[] {
* web-tree-sitter directly.
*/
export function parse(source: string, language: Language): TSTree | null {
if (language === "ruby") {
if (!isPrismLoaded()) return null;
const result = parseRubySource(source);
return result ? { rootNode: NULL_TS_NODE } : null;
}
const parser = getParser(language);
if (!parser) return null;
const tree = parser.parse(source);
Expand Down
2 changes: 2 additions & 0 deletions src/graph/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { typescriptExtractor, tsxExtractor } from "./typescript.js";
import { javascriptExtractor, jsxExtractor } from "./javascript.js";
import { pythonExtractor } from "./python.js";
import { rustExtractor } from "./rust.js";
import { rubyExtractor } from "./ruby.js";

/** Registered extractors, keyed by the language id they emit. */
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
Expand All @@ -21,6 +22,7 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
jsx: jsxExtractor,
python: pythonExtractor,
rust: rustExtractor,
ruby: rubyExtractor,
};

/** The extractor for a language, or undefined if unsupported in this release. */
Expand Down
Loading