Skip to content
Merged
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
59 changes: 59 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# AGENTS.md

## Project

VSCode extension that copies the full Ruby member reference (module/class/method/constant path) under the cursor to the clipboard. Uses regex-based parsing with indentation tracking — no AST / parser gem.

## Stack

- **Language:** TypeScript, target ES2022, module Node16
- **Runtime:** VSCode ^1.95.0, Node
- **Bundler:** esbuild
- **Test:** mocha via @vscode/test-cli (run inside VSCode debug — not standalone)
- **Lint:** eslint with @typescript-eslint
- **Package:** vsce

## Scripts

- `yarn compile` — check types + lint + bundle
- `yarn test` — compile tests + build + lint + run in VSCode
- `yarn run check-types` — `tsc --noEmit`
- `yarn run lint` — `eslint src`

## Source layout

- `src/extension.ts` — activation entry point, registers command
- `src/copy-member-reference.ts` — command handler: reads editor, writes clipboard
- `src/get-member-reference.ts` — gets current line + text above, feeds `NamespaceBuilder`
- `src/namespace-builder.ts` — builds the member path string from parsed members
- `src/path-parser.ts` — regex-based parser with indentation-aware scoping; defines `Matcher` classes for each Ruby construct (module, class, instance/class method, constant, rake tasks)
- `src/test/suite/namespace-builder.test.ts` — unit tests for parser via `NamespaceBuilder`

## Architecture

1. `getMemberReference()` reads the current line and all lines above it up to the cursor.
2. `PathParser` scans those lines with combined regexes, tracking indent levels to build a stack of enclosing members.
3. `NamespaceBuilder` joins the member path with `::` / `.` / `#` separators.
4. The result is written to clipboard and shown in an info dialog.

## Key design decisions

- **No Ruby runtime dependency.** Parser is pure regex. Indentation-based scoping (no AST).
- **Single command** — `copy-ruby-member-reference.copyReference`. No keybinding by default.
- **Modal dialogs** — both success and failure use `{modal: true}` to guarantee user sees them.

## Indentation scoping

Parser assumes consistent indentation (no tab-mixing). Deeper indent = child scope, same indent = sibling (replaces previous at that level), shallower indent = dedent. Works correctly with `end` keywords implicitly (they dedent).

## Releasing

```
npm version [patch|minor|major]
# update CHANGELOG.md, commit, merge to main
npx vsce publish
```

## Dependency resolution note

`serialize-javascript` is pinned via yarn resolutions to ^7.0.5 for a security CVE in the mocha → serialize-javascript chain. Remove when mocha 12+ ships with serialize-javascript 7+. See `RESOLUTIONS.md`.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how

## Unreleased

## 1.6.0

- Add support to class methods scoped with `extend self` and `module_function`

## 1.5.0

- Remove clipboardy dependency in favour of native VSCode clipboard API
Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ No keyboard shortcut is provided at the moment to prevent conflicts with other e

Follow the [keybindings documentation](https://code.visualstudio.com/docs/getstarted/keybindings) to map the `copy-ruby-member-reference.copyReference` command to your own keybinding.

## Known Issues
## Known limitations

Some class method definitions are not understood by the extension and might be copied as instance methods, these patterns include:
To avoid additional dependencies or a language-server integration, parsing is built into the extension as a regex-based engine relying on proper indentation.

- `extend self`
- `module_function`

Parsing currently relies on proper indentation levels.
It won't identify references inside methods.

## Release Notes

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "copy-ruby-member-reference",
"displayName": "Copy Ruby Member Reference",
"description": "Copies full namespace path for references under the cursor",
"version": "1.5.0",
"version": "1.6.0",
"author": "Enrico Luz",
"publisher": "ricobl",
"repository": "https://github.com/ricobl/copy-ruby-member-reference.git",
Expand Down
79 changes: 70 additions & 9 deletions src/path-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ interface MemberOptions {
type: string;
name: string;
separator: string;
matcher: Matcher;
}

export class Member {
type: string;
name: string;
separator: string;
matcher: Matcher;

constructor({type, name, separator} : MemberOptions) {
constructor({type, name, separator, matcher} : MemberOptions) {
this.type = type;
this.name = name;
this.separator = separator;
this.matcher = matcher;
}
}

Expand All @@ -21,19 +24,22 @@ interface MatcherOptions {
expression: string;
separator?: string;
requiredParent?: string;
promoteParentType?: string;
}

class Matcher {
type: string;
expression: string;
separator: string;
requiredParent: string | undefined;
promoteParentType: string | undefined;

constructor({type, expression, separator='::', requiredParent}: MatcherOptions) {
constructor({type, expression, separator='::', requiredParent, promoteParentType}: MatcherOptions) {
this.type = type;
this.expression = expression;
this.separator = separator;
this.requiredParent = requiredParent;
this.promoteParentType = promoteParentType;
}

matchLine(line: string, parent: Member | undefined) : Member | null {
Expand All @@ -51,7 +57,8 @@ class Matcher {
return new Member({
type: this.type,
name: name || '',
separator: this.separator
separator: this.separator,
matcher: this,
});
}

Expand All @@ -64,24 +71,59 @@ class Matcher {
}

const MATCHERS = [
// CLASSES / MODULES
new Matcher({type: 'class', expression: 'class\\s+([\\w:]+)'}),
new Matcher({type: 'module', expression: 'module\\s+([\\w:]+)'}),
new Matcher({type: 'class_method', expression: 'def\\s+self\.([\\w]+[!=\?]?)', separator: '.'}),
new Matcher({type: 'class_self', expression: 'class << self', separator: ''}),

// MODIFIERS — change parent member's type so subsequent matchers key off it
new Matcher({
type: 'extend_self',
expression: 'extend self\\b',
separator: '',
promoteParentType: 'extend_self_scope',
}),
new Matcher({
type: 'module_function',
// Ensure not to match against `module_function :method_name`
expression: 'module_function\\b(?!\\s*:\\s*\\w)',
separator: '',
promoteParentType: 'module_function_scope',
}),

// SCOPED METHODS — match bare `def name` when parent type was modified
new Matcher({
type: 'scoped_method',
expression: 'def\\s+([\\w]+[!=\\?]?)',
separator: '.',
requiredParent: 'extend_self_scope',
}),
new Matcher({
type: 'scoped_method',
expression: 'def\\s+([\\w]+[!=\\?]?)',
separator: '.',
requiredParent: 'module_function_scope',
}),

// CLASS METHODS
new Matcher({
type: 'inline_class_method',
expression: 'private_class_method def\\s+self\.([\\w]+[!=\?]?)',
separator: '.'
}),
new Matcher({type: 'class_self', expression: 'class << self', separator: ''}),
new Matcher({
type: 'class_self_method',
expression: 'def\\s+([\\w]+[!=\?]?)',
expression: 'def\\s+([\\w]+[!=\\?]?)',
separator: '.',
requiredParent: 'class_self',
}),
new Matcher({type: 'instance_method', expression: 'def\\s+([\\w]+[!=\?]?)', separator: '#'}),

// INSTANCE / CONSTANTS
new Matcher({type: 'instance_method', expression: 'def\\s+([\\w]+[!=\\?]?)', separator: '#'}),
new Matcher({type: 'constant', expression: '([A-Z][\\w]*)\\s+='}),
// Rake tasks

// RAKE TASKS
new Matcher({type: 'rake_namespace', expression: 'namespace \:([\\w]+)'}),
new Matcher({
type: 'rake_task',
Expand Down Expand Up @@ -130,6 +172,12 @@ export class PathParser {
this.lastIndentLength = indent.length;

this.currentMember = this.nextMember(line, this.parentMember());

if (this.currentMember?.matcher.promoteParentType) {
this.promoteParent(this.currentMember.matcher.promoteParentType);
continue;
}

if (!this.currentMember) {
continue;
}
Expand All @@ -139,11 +187,24 @@ export class PathParser {
}

nextMember(line: string, parent: Member | undefined): Member | undefined {
let member;
MATCHERS.find(m => member = m.matchLine(line, parent));
let member: Member | undefined;
MATCHERS.find(m => {
const result = m.matchLine(line, parent);
if (result) {member = result;}
return result !== null;
});
return member;
}

promoteParent(type: string): undefined {
const parent = this.parentMember();
if (!parent) {
return;
}

parent.type = type;
}

parentMember(): Member | undefined {
return this.memberPath[this.memberPath.length - 1];
}
Expand Down
66 changes: 66 additions & 0 deletions src/test/suite/namespace-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,72 @@ suite('NamespaceBuilder', () => {
namespaceEquals(source, 'Mod::Klass.class_m');
});

test('handles methods after `extend self`', () => {
let source = [
'module Mod',
' extend self',
' def foo'
].join('\n');
namespaceEquals(source, 'Mod.foo');
});

test('handles methods after `module_function`', () => {
let source = [
'module Mod',
' module_function',
' def foo'
].join('\n');
namespaceEquals(source, 'Mod.foo');
});

test('handles multiple methods after `extend self`', () => {
let source = [
'module Mod',
' extend self',
' def foo',
' def bar'
].join('\n');
namespaceEquals(source, 'Mod.bar');
});

test('handles `extend self` in a class', () => {
let source = [
'class Klass',
' extend self',
' def class_m'
].join('\n');
namespaceEquals(source, 'Klass.class_m');
});

test('handles `module_function` in a class', () => {
let source = [
'class Klass',
' module_function',
' def class_m'
].join('\n');
namespaceEquals(source, 'Klass.class_m');
});

test('instance methods before `extend self` still use #', () => {
let source = [
'module Mod',
' def instance_m',
' extend self',
' def class_m'
].join('\n');
namespaceEquals(source, 'Mod.class_m');
});

test('methods in nested class ignore outer `extend self`', () => {
let source = [
'module Mod',
' extend self',
' class Inner',
' def inner_m'
].join('\n');
namespaceEquals(source, 'Mod::Inner#inner_m');
});

test('handles constants', () => {
let source = [
'module Mod',
Expand Down
Loading