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
57 changes: 56 additions & 1 deletion components/entities/semantic-schema/api-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import { ComponentID } from '@teambit/component-id';
import { ExportSchema, ModuleSchema } from './schemas';
import { ExportSchema, ModuleSchema, 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 +134,61 @@ 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());
}

/**
* the declarations the component exports, following an exported reference to the local declaration
* it points at — `export default Button` exports a reference to `Button`.
*/
listExportedDeclarations(): SchemaNode[] {
return this.module.listExports().map((node) => {
return (TypeRefSchema.isTypeRefSchema(node) && this.resolveRef(node)) || node;
});
}

/**
* finds a declaration of this component by name. with `filePath`, only a declaration internal to that
* file matches. without it, the name may also be one the component exports the declaration under
* (`export { Props as ButtonProps }`).
*/
findDeclaration(name: string, filePath?: string): SchemaNode | undefined {
const candidates = this.listDeclarations().filter((node) => node.name === name);
if (filePath) return candidates.find((node) => node.location.filePath === filePath);
return candidates[0] || this.findExportedAs(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;
return this.findDeclaration(ref.name, ref.internalFilePath);
}

private findExportedAs(name: string): SchemaNode | undefined {
for (const module of [this.module, ...this.internals]) {
const found = module.findExport(name);
if (found) return found;
}
return undefined;
}

/**
* 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
258 changes: 258 additions & 0 deletions components/entities/semantic-schema/schema-members.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
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('only keeps a union member required when every alternative requires it', () => {
// `{ id: string; a: string } | { id: string; a?: string; b: string }`
const union = new TypeUnionSchema(loc, [
literal(member('id', 'string', false), member('a', 'string', false)),
literal(member('id', 'string', false), member('a', 'string', true), member('b', 'string', false)),
]);
const required = union.getMembers().map((m) => `${m.name}${(m as VariableLikeSchema).isOptional ? '?' : ''}`);
expect(required).to.deep.equal(['id', 'a?', 'b?']);
});

it('unions the types the alternatives give one member', () => {
// `{ value: string } | { value: number }`
const union = new TypeUnionSchema(loc, [
literal(member('value', 'string', false)),
literal(member('value', 'number', false)),
]);
const [value] = union.getMembers() as VariableLikeSchema[];
expect(value.type.toString()).to.equal('string | number');
expect(value.isOptional).to.equal(false);
});

it('lets a type shared by two branches contribute to each', () => {
const base = iface('Base', [member('id', 'string', false)]);
const shared = new TypeRefSchema(loc, 'Base');
const union = new TypeUnionSchema(loc, [
new TypeIntersectionSchema(loc, [shared, literal(member('a'))]),
new TypeIntersectionSchema(loc, [shared, literal(member('b'))]),
]);
const resolveRef = () => base;
const members = union.getMembers({ resolveRef }) as VariableLikeSchema[];
expect(names(members)).to.deep.equal(['id', 'a', 'b']);
expect(members[0].isOptional).to.equal(false);
});

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 the name a declaration is exported under', () => {
// `export { Props as ButtonProps }`
const schema = api([new ExportSchema(loc, 'Props', iface('Props', [member('a')]), 'ButtonProps')]);
expect(names(schema.getMembersOf(new TypeRefSchema(loc, 'ButtonProps')))).to.deep.equal(['a']);
});

it('follows an exported reference to the local declaration it points at', () => {
// `const Button = ...; export default Button` — the export is a reference, the declaration is internal.
const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 };
const button = new TypeSchema(inButton, 'Button', literal(member('a')), 'type Button');
const schema = api(
[new ExportSchema(loc, 'default', new TypeRefSchema(loc, 'Button', undefined, undefined, 'button.tsx'))],
[button]
);
expect(schema.listExportedDeclarations()).to.deep.equal([button]);
});

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);
});
});
41 changes: 40 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;
/**
* the nodes being expanded on the current path. 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,29 @@ 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. a node already being expanded on the current path contributes nothing,
* so self-referencing types terminate — while a type shared by two branches (say, a base of both
* alternatives of a union) still contributes to each.
*/
protected static membersOf(node: SchemaNode | undefined, context: GetMembersContext): SchemaNode[] {
if (!node) return [];
const visited = new Set(context.visited);
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
Loading
Loading