Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
36 changes: 36 additions & 0 deletions components/entities/semantic-schema/api-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import chalk from 'chalk';
import { ComponentID } from '@teambit/component-id';
import { ExportSchema, ModuleSchema } from './schemas';
import type { TypeRefSchema } from './schemas';
import type { SchemaLocation } from './schema-node';
import { SchemaNode } from './schema-node';
import { TagName } from './schemas/docs/tag';
Expand Down Expand Up @@ -134,6 +135,41 @@ export class APISchema extends SchemaNode {
return sectionNameMap[constructorName] || constructorName;
}

/**
* every declaration of the component: the index module's, then the internal modules'.
*/
listDeclarations(): SchemaNode[] {
return [this.module, ...this.internals].flatMap((module) => module.listDeclarations());
}

/**
* finds a declaration of this component by name, exported or internal.
*/
findDeclaration(name: string): SchemaNode | undefined {
return this.listDeclarations().find((node) => node.name === name);
}

/**
* resolves a type reference to the declaration it points at. references to other components or to
* packages resolve to nothing: their declarations are not part of this schema. a reference to a
* declaration internal to a file only resolves within that file, so same-named declarations in other
* files are never mistaken for it.
*/
resolveRef(ref: TypeRefSchema): SchemaNode | undefined {
if (!ref.isFromThisComponent()) return undefined;
const candidates = this.listDeclarations().filter((node) => node.name === ref.name);
if (!ref.internalFilePath) return candidates[0];
return candidates.find((node) => node.location.filePath === ref.internalFilePath);
Comment thread
luvkapur marked this conversation as resolved.
Outdated
}

/**
* the members an object-like type contributes, resolving references within this component.
* see `SchemaNode.getMembers()`.
*/
getMembersOf(node: SchemaNode): SchemaNode[] {
return node.getMembers({ resolveRef: (ref) => this.resolveRef(ref) });
}

listSignatures() {
return this.module.exports.map((exp) => exp.signature);
}
Expand Down
207 changes: 207 additions & 0 deletions components/entities/semantic-schema/schema-members.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { expect } from 'chai';
import { ComponentID } from '@teambit/component-id';
import type { SchemaLocation, SchemaNode } from './schema-node';
import { APISchema } from './api-schema';
import {
ExportSchema,
ExpressionWithTypeArgumentsSchema,
InferenceTypeSchema,
InterfaceSchema,
KeywordTypeSchema,
ModuleSchema,
ParameterSchema,
ParenthesizedTypeSchema,
TypeIntersectionSchema,
TypeLiteralSchema,
TypeRefSchema,
TypeSchema,
TypeUnionSchema,
VariableLikeSchema,
} from './schemas';

const loc: SchemaLocation = { filePath: 'index.ts', line: 0, character: 0 };
const compId = ComponentID.fromString('org.scope/button');

function member(name: string, type = 'string', isOptional = true): VariableLikeSchema {
return new VariableLikeSchema(loc, name, `${name}: ${type}`, new KeywordTypeSchema(loc, type), isOptional);
}

function iface(name: string, members: SchemaNode[], extendsNodes: ExpressionWithTypeArgumentsSchema[] = []) {
return new InterfaceSchema(loc, name, `interface ${name}`, extendsNodes, members);
}

function literal(...members: SchemaNode[]) {
return new TypeLiteralSchema(loc, members);
}

function extendsRef(name: string) {
return new ExpressionWithTypeArgumentsSchema([], new TypeRefSchema(loc, name), name, loc);
}

const names = (nodes: SchemaNode[]) => nodes.map((node) => node.name);

describe('SchemaNode.getMembers()', () => {
it('returns the members of an interface', () => {
expect(names(iface('Props', [member('a'), member('b')]).getMembers())).to.deep.equal(['a', 'b']);
});

it('returns the members of a type literal', () => {
expect(names(literal(member('a')).getMembers())).to.deep.equal(['a']);
});

it('contributes nothing for a type that is not object-like', () => {
expect(new KeywordTypeSchema(loc, 'string').getMembers()).to.deep.equal([]);
});

it('combines the members of an intersection', () => {
const intersection = new TypeIntersectionSchema(loc, [literal(member('a')), iface('B', [member('b')])]);
expect(names(intersection.getMembers())).to.deep.equal(['a', 'b']);
});

it('lists the members of every alternative of a union', () => {
const union = new TypeUnionSchema(loc, [literal(member('a'), member('shared')), literal(member('b'))]);
expect(names(union.getMembers())).to.deep.equal(['a', 'shared', 'b']);
});

it('follows a type alias and parentheses to the underlying type', () => {
const alias = new TypeSchema(loc, 'Props', new ParenthesizedTypeSchema(loc, literal(member('a'))), 'type Props');
expect(names(alias.getMembers())).to.deep.equal(['a']);
});

it('follows an export wrapper to the exported declaration', () => {
expect(names(new ExportSchema(loc, 'Props', iface('Props', [member('a')])).getMembers())).to.deep.equal(['a']);
});

it('resolves a reference through the context, and contributes nothing without one', () => {
const ref = new TypeRefSchema(loc, 'Props');
const target = iface('Props', [member('a')]);
expect(names(ref.getMembers({ resolveRef: () => target }))).to.deep.equal(['a']);
expect(ref.getMembers()).to.deep.equal([]);
});

it('includes the members an interface inherits, after its own', () => {
const base = iface('Base', [member('a')]);
const derived = iface('Props', [member('b')], [extendsRef('Base')]);
const resolveRef = (ref: TypeRefSchema) => (ref.name === 'Base' ? base : undefined);
expect(names(derived.getMembers({ resolveRef }))).to.deep.equal(['b', 'a']);
});

it('terminates on a self-referencing type', () => {
const self = new TypeRefSchema(loc, 'Props');
const alias = new TypeSchema(
loc,
'Props',
new TypeIntersectionSchema(loc, [self, literal(member('a'))]),
'type Props'
);
expect(names(alias.getMembers({ resolveRef: () => alias }))).to.deep.equal(['a']);
});
});

describe('ModuleSchema.listExports() / listDeclarations()', () => {
const namespace = new ModuleSchema(loc, [iface('Inner', [])], []);
namespace.namespace = 'ns';
const mod = new ModuleSchema(
loc,
[new ExportSchema(loc, 'Props', iface('Props', [])), namespace, iface('Other', [])],
[iface('Internal', [])]
);

it('lists exports with wrappers and nested namespaces unwrapped', () => {
expect(names(mod.listExports())).to.deep.equal(['Props', 'Inner', 'Other']);
});

it('lists internals after the exports', () => {
expect(names(mod.listDeclarations())).to.deep.equal(['Props', 'Inner', 'Other', 'Internal']);
});

it('does not mutate the module', () => {
mod.listExports();
expect(mod.exports).to.have.lengthOf(3);
expect(mod.exports[0]).to.be.instanceOf(ExportSchema);
});
});

describe('APISchema.getMembersOf()', () => {
function api(exports: SchemaNode[], internals: SchemaNode[] = [], internalModules: ModuleSchema[] = []) {
return new APISchema(loc, new ModuleSchema(loc, exports, internals), internalModules, compId);
}

it('resolves references to exported and internal declarations of the component', () => {
const schema = api(
[new ExportSchema(loc, 'Props', iface('Props', [member('a')]))],
[new TypeSchema(loc, 'FileInternal', literal(member('b')), 'type FileInternal')],
[new ModuleSchema(loc, [], [new TypeSchema(loc, 'ModuleInternal', literal(member('c')), 'type ModuleInternal')])]
);
const props = new TypeIntersectionSchema(loc, [
new TypeRefSchema(loc, 'Props'),
new TypeRefSchema(loc, 'FileInternal'),
new TypeRefSchema(loc, 'ModuleInternal'),
]);
expect(names(schema.getMembersOf(props))).to.deep.equal(['a', 'b', 'c']);
});

it('resolves through an alias of a reference', () => {
const schema = api([
new TypeSchema(loc, 'Props', new TypeRefSchema(loc, 'Base'), 'type Props'),
iface('Base', [member('a')]),
]);
expect(names(schema.getMembersOf(new TypeRefSchema(loc, 'Props')))).to.deep.equal(['a']);
});

it('does not resolve references to other components or packages, even with a matching name', () => {
const schema = api([iface('Props', [member('a')])]);
const fromComponent = new TypeRefSchema(loc, 'Props', ComponentID.fromString('org.scope/other'));
const fromPackage = new TypeRefSchema(loc, 'Props', undefined, 'react');
expect(schema.getMembersOf(fromComponent)).to.deep.equal([]);
expect(schema.getMembersOf(fromPackage)).to.deep.equal([]);
});

it('contributes nothing for an unknown reference', () => {
expect(api([]).getMembersOf(new TypeRefSchema(loc, 'Missing'))).to.deep.equal([]);
});

it('resolves a file-internal reference only within its file', () => {
const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 };
const inCard: SchemaLocation = { filePath: 'card.tsx', line: 1, character: 1 };
const buttonProps = new TypeSchema(inButton, 'Props', literal(member('label')), 'type Props');
const cardProps = new TypeSchema(inCard, 'Props', literal(member('title')), 'type Props');
const schema = api(
[],
[],
[new ModuleSchema(inCard, [], [cardProps]), new ModuleSchema(inButton, [], [buttonProps])]
);

const toButton = new TypeRefSchema(loc, 'Props', undefined, undefined, 'button.tsx');
const toElsewhere = new TypeRefSchema(loc, 'Props', undefined, undefined, 'missing.tsx');
expect(names(schema.getMembersOf(toButton))).to.deep.equal(['label']);
expect(schema.getMembersOf(toElsewhere)).to.deep.equal([]);
});
});

describe('ParameterSchema.getBindingDefaults()', () => {
it('collects default values from destructured bindings, whatever node describes them', () => {
const param = new ParameterSchema(loc, 'props', new TypeRefSchema(loc, 'Props'), false, undefined, undefined, [
new InferenceTypeSchema(loc, 'number', 'size', '32'),
new VariableLikeSchema(
loc,
'label',
'label: string',
new KeywordTypeSchema(loc, 'string'),
true,
undefined,
"'hi'"
),
new InferenceTypeSchema(loc, 'string', 'other'),
]);
expect([...param.getBindingDefaults()]).to.deep.equal([
['size', '32'],
['label', "'hi'"],
]);
});

it('is empty for a parameter without bindings', () => {
const param = new ParameterSchema(loc, 'props', new TypeRefSchema(loc, 'Props'), false);
expect(param.getBindingDefaults().size).to.equal(0);
});
});
39 changes: 38 additions & 1 deletion components/entities/semantic-schema/schema-node.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { pickBy } from 'lodash';
import pluralize from 'pluralize';
import type { DocSchema } from './schemas';
import type { DocSchema, TypeRefSchema } from './schemas';
import type { SchemaChangeFact } from './schema-diff';
import { deepEqualNoLocation, diffDoc } from './schema-diff';

/**
* context for `SchemaNode.getMembers()`.
*/
export type GetMembersContext = {
/**
* resolves a type reference to the declaration it points at, e.g. by name within an `APISchema`.
* without it, references contribute no members.
*/
resolveRef?: (ref: TypeRefSchema) => SchemaNode | undefined;
/**
* nodes already being expanded in this traversal. guards against self-referencing types.
*/
visited?: Set<SchemaNode>;
};

export interface ISchemaNode {
__schema: string;
name?: string;
Expand All @@ -17,6 +32,7 @@ export interface ISchemaNode {
getNodes(): SchemaNode[];
findNode(predicate: (node: SchemaNode) => boolean, visitedNodes?: Set<SchemaNode>): SchemaNode | undefined;
getAllNodesRecursively(visitedNodes?: Set<SchemaNode>): SchemaNode[];
getMembers(context?: GetMembersContext): SchemaNode[];
diff(other: SchemaNode): SchemaChangeFact[];
}

Expand Down Expand Up @@ -88,6 +104,27 @@ export abstract class SchemaNode implements ISchemaNode {
return undefined;
}

/**
* the members this node contributes when it describes an object-like type: the members of an interface or
* a type literal, the combined members of an intersection, or the members of whatever an alias, parentheses
* or a type reference resolve to. nodes that don't describe an object type contribute none.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getMembers(context: GetMembersContext = {}): SchemaNode[] {
return [];
}

/**
* the members of a child node, expanded at most once per traversal so self-referencing types terminate.
*/
protected static membersOf(node: SchemaNode | undefined, context: GetMembersContext): SchemaNode[] {
if (!node) return [];
const visited = context.visited || new Set<SchemaNode>();
if (visited.has(node)) return [];
visited.add(node);
return node.getMembers({ ...context, visited });
}

/**
* Compute neutral change facts between this node and another node of the same type.
* Subclasses should override with type-specific comparison logic.
Expand Down
6 changes: 5 additions & 1 deletion components/entities/semantic-schema/schemas/export.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SchemaLocation } from '../schema-node';
import type { GetMembersContext, SchemaLocation } from '../schema-node';
import { SchemaNode } from '../schema-node';
import { DocSchema } from './docs';
import { SchemaRegistry } from '../schema-registry';
Expand All @@ -23,6 +23,10 @@ export class ExportSchema extends SchemaNode {
this.signature = exportNode.signature || this.toFullSignature();
}

getMembers(context: GetMembersContext = {}) {
return SchemaNode.membersOf(this.exportNode, context);
}

toString(options?: { color?: boolean }): string {
let signature = '';

Expand Down
10 changes: 9 additions & 1 deletion components/entities/semantic-schema/schemas/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import chalk from 'chalk';
import type { SchemaLocation } from '../schema-node';
import type { GetMembersContext, SchemaLocation } from '../schema-node';
import { SchemaNode } from '../schema-node';
import type { SchemaChangeFact } from '../schema-diff';
import { diffMembers } from '../schema-diff-members';
Expand Down Expand Up @@ -29,6 +29,14 @@ export class InterfaceSchema extends SchemaNode {
return this.members;
}

/**
* own members first, then the ones inherited through `extends` — so an override shadows what it overrides.
*/
getMembers(context: GetMembersContext = {}) {
const inherited = (this.extendsNodes || []).flatMap((node) => SchemaNode.membersOf(node.expression, context));
return [...this.members, ...inherited];
}

toString(options?: { color?: boolean }): string {
const boldUnderline = options?.color ? chalk.bold.underline : (str: string) => str;
const membersStr = this.members.map((m) => `* ${m.toString(options)}`).join('\n');
Expand Down
23 changes: 22 additions & 1 deletion components/entities/semantic-schema/schemas/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import chalk from 'chalk';
import type { SchemaLocation } from '../schema-node';
import { SchemaNode } from '../schema-node';
import { SchemaRegistry } from '../schema-registry';
import type { ExportSchema } from './export';
import { ExportSchema } from './export';

export class ModuleSchema extends SchemaNode {
// exports could either be re exports (export declarations) or nodes with export modifier
Expand All @@ -24,6 +24,27 @@ export class ModuleSchema extends SchemaNode {
return [...this.exports, ...this.internals];
}

/**
* the declarations this module exports, with export wrappers and nested namespaces unwrapped.
* unlike `flatExportsRecursively()`, this doesn't mutate the module.
*/
listExports(): SchemaNode[] {
return this.exports.flatMap((node) => ModuleSchema.unwrap(node));
}

/**
* every declaration in this module: the exports, then the internals.
*/
listDeclarations(): SchemaNode[] {
return [...this.listExports(), ...this.internals.flatMap((node) => ModuleSchema.unwrap(node))];
}

private static unwrap(node: SchemaNode): SchemaNode[] {
if (ExportSchema.isExportSchema(node)) return ModuleSchema.unwrap(node.exportNode);
if (ModuleSchema.isModuleSchema(node)) return node.listExports();
return [node];
}

flatExportsRecursively() {
this.exports = this.exports.reduce(
(acc, exp) => {
Expand Down
Loading
Loading