From b8f5c060518a7cb09a17a1c87594eed0d87ded35 Mon Sep 17 00:00:00 2001 From: Enrico Date: Mon, 29 Jun 2026 16:58:39 +1000 Subject: [PATCH 1/5] Add AGENTS.md for AI agent context --- AGENTS.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..24c7aa2 --- /dev/null +++ b/AGENTS.md @@ -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`. From 5a98fa77a3a9beba20e64388667d4622608e8c7d Mon Sep 17 00:00:00 2001 From: Enrico Date: Mon, 29 Jun 2026 22:38:50 +1000 Subject: [PATCH 2/5] Include support for scoped methods (extend self / module_function) --- src/path-parser.ts | 79 +++++++++++++++++++++--- src/test/suite/namespace-builder.test.ts | 66 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/path-parser.ts b/src/path-parser.ts index 41426a7..16b4be5 100644 --- a/src/path-parser.ts +++ b/src/path-parser.ts @@ -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; } } @@ -21,6 +24,7 @@ interface MatcherOptions { expression: string; separator?: string; requiredParent?: string; + promoteParentType?: string; } class Matcher { @@ -28,12 +32,14 @@ class Matcher { 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 { @@ -51,7 +57,8 @@ class Matcher { return new Member({ type: this.type, name: name || '', - separator: this.separator + separator: this.separator, + matcher: this, }); } @@ -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', @@ -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; } @@ -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]; } diff --git a/src/test/suite/namespace-builder.test.ts b/src/test/suite/namespace-builder.test.ts index aad5b99..02bc9c5 100644 --- a/src/test/suite/namespace-builder.test.ts +++ b/src/test/suite/namespace-builder.test.ts @@ -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', From f693609ab756360fb28522dd06eca518d57a69b8 Mon Sep 17 00:00:00 2001 From: Enrico Date: Tue, 30 Jun 2026 21:03:21 +1000 Subject: [PATCH 3/5] Update limitations on README --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 079ef32..7f2bcd8 100644 --- a/README.md +++ b/README.md @@ -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 From 020b22f3bd530e4240f9392f376209d584dc6334 Mon Sep 17 00:00:00 2001 From: Enrico Date: Tue, 30 Jun 2026 21:24:48 +1000 Subject: [PATCH 4/5] Changelog for 1.6.0 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c72df..de54b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 06c9f87cdf51604e0357777633ec372170766402 Mon Sep 17 00:00:00 2001 From: Enrico Date: Tue, 30 Jun 2026 21:24:51 +1000 Subject: [PATCH 5/5] 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b0c8b3..bf53638 100644 --- a/package.json +++ b/package.json @@ -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",