fix(r): read slots and fields given as c(...) vectors

The slot pattern matched only list(...) and representation(...), although
the field extractor accepts any call, so setClass("A", slots = c(a =
"numeric")) and setRefClass("R", fields = c(...)) created no Property nodes.

Accept c(...) for both. An unnamed string in slots = c("a", "b") or
fields = c("a", "b") names an untyped member, as in R; an unnamed string in
representation(...) or list(...) is still not a member. The new pattern
captures its callee as @_vecOwner, so the class-naming capture filter does
not mistake these strings for the class name.
This commit is contained in:
Mick Cooney 2026-10-16 15:52:26 +01:00
parent 0476330429
commit e45fa2e080
7 changed files with 181 additions and 2 deletions

View file

@ -18,6 +18,7 @@ Definitions:
- S4 classes (`setClass`), generics (`setGeneric`), methods (`setMethod`) and slots (`representation`, `slots`).
- R6 classes with their methods, fields and active bindings.
- RefClass (`setRefClass`) classes with their methods and fields.
- Slots and fields given as `list(...)`, `representation(...)` or `c(...)`. A named entry (`c(a = "numeric")`) records its type; an unnamed string in `slots = c("a", "b")` or `fields = c("a", "b")` names an untyped member, as in R. An unnamed string in `representation(...)` or `list(...)` is not a member.
- The argument that names an S4 or RefClass class, a generic or a method is the argument spelled with the first formal (`Class=`, `name=`, `f=`) wherever it sits, else the first unnamed argument (so a later `Class=` takes the formal from an earlier unnamed argument, as in R). Other strings (`contains=`, `valueClass=`, a `setMethod` signature) create no node.
Types:

View file

@ -129,13 +129,32 @@ export class RFieldExtractor extends BaseFieldExtractor {
const slotArgs = slotCall.childForFieldName('arguments');
if (!slotArgs) continue;
// Only an unnamed character vector (`slots = c("a", "b")`) names its slots by
// their strings; elsewhere an unnamed string is not a slot (`representation("Base")`).
const unnamedNamesSlots =
paramName !== 'representation' && slotCall.childForFieldName('function')?.text === 'c';
for (let j = 0; j < slotArgs.namedChildCount; j++) {
const slot = slotArgs.namedChild(j);
if (!slot || slot.type !== 'argument') continue;
const slotName = slot.childForFieldName('name');
const slotType = slot.childForFieldName('value');
if (!slotName) continue;
if (!slotName) {
const unnamed = unnamedNamesSlots ? extractQuotedTypeName(slotType) : null;
if (unnamed) {
fields.push({
name: unnamed,
type: null,
visibility: 'public',
isStatic: false,
isReadonly: false,
sourceFile: context.filePath,
line: slot.startPosition.row + 1,
});
}
continue;
}
const typeName = extractQuotedTypeName(slotType);

View file

@ -3242,10 +3242,31 @@ export const R_QUERIES = `
(#match? @_slotArg "^(representation|slots|fields)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^(list|representation)$")
(#match? @_listfn "^(list|representation|c)$")
arguments: (arguments
(argument
name: (identifier) @name) @definition.property)))))
; ── S4 / RefClass unnamed names: slots = c("a", "b"), fields = c("a", "b") ──
; An unnamed character vector names the slots or fields (their class is ANY). Only
; c(...) reads this way: an unnamed string in representation(...) is a superclass.
; The callee is captured as @_vecOwner, not @_fn, so these string captures are not
; mistaken for the class-naming argument by isRNonNamingArgumentCapture.
(call
function: (identifier) @_vecOwner
(#match? @_vecOwner "^(setClass|setRefClass)$")
arguments: (arguments
(argument
name: (identifier) @_vecArg
(#match? @_vecArg "^(slots|fields)$")
value: (call
function: (identifier) @_vecfn
(#match? @_vecfn "^c$")
arguments: (arguments
(argument
!name
value: (string
content: (string_content) @name)) @definition.property)))))
`;
import { SupportedLanguages } from 'gitnexus-shared';

View file

@ -0,0 +1,5 @@
# slots / fields given as c(...) rather than list(...).
setClass("Person", slots = c(name = "character", age = "numeric"))
setClass("Tagged", slots = c("tag", "weight"))
Account <- setRefClass("Account", fields = c(balance = "numeric"))
Ledger <- setRefClass("Ledger", fields = c("entries"))

View file

@ -0,0 +1,45 @@
/**
* R: `slots = c(...)` and `fields = c(...)` are the forms `methods::setClass` and
* `setRefClass` document. They used to create no Property nodes, although the
* `slots = list(...)` and `representation(...)` forms did.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo } from './helpers.js';
import type { PipelineResult } from './helpers.js';
// Cold worker-pool startups otherwise flake against the 5s default ready budget
// on a loaded runner, failing for reasons unrelated to R parsing or resolution.
// Safe under `pool: 'forks'`: the env stub is process-global, but each file gets its own process.
beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'));
afterAll(() => vi.unstubAllEnvs());
describe('R slots / fields given as a c(...) vector', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-vector-members'), () => {});
}, 60000);
it('creates a Property for every named and unnamed entry, owned by its class', () => {
const owned = getRelationships(result, 'HAS_PROPERTY')
.map((e) => `${e.source}->${e.target}`)
.sort();
expect(owned).toEqual([
'Account->balance',
'Ledger->entries',
'Person->age',
'Person->name',
'Tagged->tag',
'Tagged->weight',
]);
});
it('records the declared type of a named entry and none for an unnamed one', () => {
const declared = (name: string) =>
result.graph.getNode(`Property:R/vectors.R:${name}`)?.properties.declaredType;
expect(declared('Person.name')).toBe('character');
expect(declared('Person.age')).toBe('numeric');
expect(declared('Tagged.tag')).toBeUndefined();
});
});

View file

@ -117,6 +117,57 @@ describe('R_QUERIES: setClass / setRefClass name only their first argument', ()
});
});
describe('R_QUERIES: slots = c(...) and fields = c(...) define properties', () => {
it('reads a named vector: c(a = "numeric")', () => {
expect(definitions('setClass("A", slots = c(a = "numeric", b = "character"))')).toEqual([
'definition.class:A',
'definition.property:a',
'definition.property:b',
]);
expect(definitions('setRefClass("R", fields = c(n = "numeric"))')).toEqual([
'definition.class:R',
'definition.property:n',
]);
});
it('reads an unnamed character vector as names: c("a", "b")', () => {
expect(definitions('setClass("A", slots = c("a", "b"))')).toEqual([
'definition.class:A',
'definition.property:a',
'definition.property:b',
]);
expect(definitions('setRefClass("R", fields = c("n", "m"))')).toEqual([
'definition.class:R',
'definition.property:n',
'definition.property:m',
]);
});
it('reads a vector that mixes named and unnamed entries', () => {
expect(definitions('setClass("A", slots = c("a", b = "numeric"))')).toEqual([
'definition.class:A',
'definition.property:a',
'definition.property:b',
]);
});
it('does not read a string inside a vector under another argument name', () => {
expect(definitions('setClass("A", contains = c("B", "C"))')).toEqual(['definition.class:A']);
expect(definitions('setClass("A", prototype = c(x = "1"))')).toEqual(['definition.class:A']);
});
it('does not read unnamed strings of representation() or list() as slots', () => {
expect(definitions('setClass("A", representation("VIRTUAL"))')).toEqual(['definition.class:A']);
expect(definitions('setClass("A", slots = list("a"))')).toEqual(['definition.class:A']);
});
it('does not read other vector builders', () => {
expect(definitions('setClass("A", slots = character(a = "numeric"))')).toEqual([
'definition.class:A',
]);
});
});
describe('R_QUERIES: setGeneric names only its first argument', () => {
it('names the generic from the first positional string (positive control)', () => {
expect(definitions('setGeneric("f", function(x) standardGeneric("f"))')).toEqual([

View file

@ -120,3 +120,40 @@ describe('R field extractor: slots attach to the Class argument', () => {
expect(propertyOwner(source)).toBeNull();
});
});
describe('RFieldExtractor: slots = c(...) / fields = c(...)', () => {
const details = (source: string) =>
extractor
.extract(classCall(source), context)
?.fields.map((f) => [f.name, f.type] as [string, string | null]);
it('reads the names and types of a named vector', () => {
expect(details('setClass("A", slots = c(a = "numeric", b = "character"))')).toEqual([
['a', 'numeric'],
['b', 'character'],
]);
expect(details('setRefClass("R", fields = c(n = "numeric"))')).toEqual([['n', 'numeric']]);
});
it('reads the strings of an unnamed vector as untyped names', () => {
expect(details('setClass("A", slots = c("a", "b"))')).toEqual([
['a', null],
['b', null],
]);
expect(details('setRefClass("R", fields = c("n"))')).toEqual([['n', null]]);
});
it('keeps named and unnamed entries of one vector together', () => {
expect(details('setClass("A", slots = c("a", b = "numeric"))')).toEqual([
['a', null],
['b', 'numeric'],
]);
});
it('does not read an unnamed string of representation() or list()', () => {
expect(
details('setClass("A", representation = representation("VIRTUAL", x = "numeric"))'),
).toEqual([['x', 'numeric']]);
expect(details('setClass("A", slots = list("a"))')).toBeUndefined();
});
});