Skip to content

Commit

Permalink
Rush Project Selector
Browse files Browse the repository at this point in the history
  • Loading branch information
elliot-nelson committed Aug 7, 2023
1 parent e67788f commit b66c79d
Show file tree
Hide file tree
Showing 15 changed files with 575 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add selector expressions to Rush CLI",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
15 changes: 15 additions & 0 deletions libraries/rush-lib/src/api/IRushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfigurationProject } from './RushConfigurationProject';
import { SelectorExpression } from './SelectorExpressions';

/**
* This interface allows a previously constructed RushProjectSelector to be passed around
* and used by other lower-level objects. (For example, the "json:" selector reads and
* parses an entire new selector expression, which might in turn load another selector
* expression and parse it.)
*/
export interface IRushProjectSelector {
selectExpression(expr: SelectorExpression, context: string): Promise<RushConfigurationProject[]>;
}
183 changes: 183 additions & 0 deletions libraries/rush-lib/src/api/RushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfiguration } from './RushConfiguration';
import { RushConfigurationProject } from './RushConfigurationProject';
import { Selection } from '../logic/Selection';
import type { ISelectorParser } from '../logic/selectors/ISelectorParser';
import type { ITerminal } from '@rushstack/node-core-library';
import {
GitChangedProjectSelectorParser,
IGitSelectorParserOptions
} from '../logic/selectors/GitChangedProjectSelectorParser';
import { NamedProjectSelectorParser } from '../logic/selectors/NamedProjectSelectorParser';
import { TagProjectSelectorParser } from '../logic/selectors/TagProjectSelectorParser';
import { VersionPolicyProjectSelectorParser } from '../logic/selectors/VersionPolicyProjectSelectorParser';
import { JsonFileSelectorParser } from '../logic/selectors/JsonFileSelectorParser';
import { SelectorError } from '../logic/selectors/SelectorError';
import {
SelectorExpression,
IExpressionDetailedSelector,
ExpressionParameter,
IExpressionOperatorAnd,
IExpressionOperatorOr,
IExpressionOperatorNot,
isDetailedSelector,
isParameter,
isAnd,
isOr,
isNot
} from './SelectorExpressions';

/**
* When preparing to select projects in a Rush monorepo, some selector scopes
* require additional configuration in order to control their behavior. This
* options interface allows the caller to provide these properties.
*/
export interface IProjectSelectionOptions {
/**
* Options required for configuring the git selector scope.
*/
gitSelectorParserOptions: IGitSelectorParserOptions;
}

/**
* A central interface for selecting a subset of Rush projects from a given monorepo,
* using standardized selector expressions. Note that the types of selectors available
* in a monorepo may be influenced in the future by plugins, so project selection
* is always done in the context of a particular Rush configuration.
*/
export class RushProjectSelector {
private _rushConfig: RushConfiguration;
private _scopes: Map<string, ISelectorParser<RushConfigurationProject>> = new Map();
private _options: IProjectSelectionOptions;

public constructor(rushConfig: RushConfiguration, options: IProjectSelectionOptions) {
this._rushConfig = rushConfig;
this._options = options;

this._scopes.set('name', new NamedProjectSelectorParser(this._rushConfig));
this._scopes.set(
'git',
new GitChangedProjectSelectorParser(this._rushConfig, this._options.gitSelectorParserOptions)
);
this._scopes.set('tag', new TagProjectSelectorParser(this._rushConfig));
this._scopes.set('version-policy', new VersionPolicyProjectSelectorParser(this._rushConfig));
this._scopes.set('json', new JsonFileSelectorParser(this._rushConfig, this));
}

/**
* Select a set of projects using the passed selector expression.
*
* The passed context string is used only when constructing error messages, in the event of
* an error in user input. The default string "expression" is used if no context is provided.
*/
public async selectExpression(
expr: SelectorExpression,
context: string = 'expression'
): Promise<RushConfigurationProject[]> {
if (isAnd(expr)) {
return this._evaluateAnd(expr, context);
} else if (isOr(expr)) {
return this._evaluateOr(expr, context);
} else if (isNot(expr)) {
return this._evaluateNot(expr, context);
} else if (isParameter(expr)) {
return this._evaluateParameter(expr, context);
} else if (isDetailedSelector(expr)) {
return this._evaluateDetailedSelector(expr, context);
} else if (typeof expr === 'string') {
return this._evaluateSimpleSelector(expr, context);
} else {
// Fail-safe... in general, this shouldn't be possible, as user script type checking
// or JSON schema validation should catch it before this point.
throw new SelectorError(`Invalid object encountered in selector expression in ${context}.`);
}
}

private async _evaluateAnd(
expr: IExpressionOperatorAnd,
context: string
): Promise<RushConfigurationProject[]> {
const result: Array<RushConfigurationProject>[] = [];
for (const operand of expr.and) {
result.push(await this.selectExpression(operand, context));
}
return [...Selection.intersection(new Set(result[0]), ...result.slice(1).map((x) => new Set(x)))];
}

private async _evaluateOr(
expr: IExpressionOperatorOr,
context: string
): Promise<RushConfigurationProject[]> {
const result: Array<RushConfigurationProject>[] = [];
for (const operand of expr.or) {
result.push(await this.selectExpression(operand, context));
}
return [...Selection.union(new Set(result[0]), ...result.slice(1).map((x) => new Set(x)))];
}

private async _evaluateNot(
expr: IExpressionOperatorNot,
context: string
): Promise<RushConfigurationProject[]> {
const result: RushConfigurationProject[] = await this.selectExpression(expr.not, context);
return this._rushConfig.projects.filter((p) => !result.includes(p));
}

private async _evaluateParameter(
expr: ExpressionParameter,
context: string
): Promise<RushConfigurationProject[]> {
const key: string = Object.keys(expr)[0];

if (key === '--to') {
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return [...Selection.expandAllDependencies(arg)];
} else if (key === '--from') {
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return [...Selection.expandAllDependencies(Selection.expandAllConsumers(arg))];
} else if (key === '--only') {
// "only" is a no-op in a generic selector expression
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return arg;
} else {
throw new SelectorError(`Unknown parameter '${key}' encountered in selector expression in ${context}.`);
}
}

private async _evaluateDetailedSelector(
expr: IExpressionDetailedSelector,
context: string
): Promise<RushConfigurationProject[]> {
const parser: ISelectorParser<RushConfigurationProject> | undefined = this._scopes.get(expr.scope);
if (!parser) {
throw new SelectorError(
`Unknown selector scope '${expr.scope}' for value '${expr.value}' in ${context}.`
);
}
return [
...(await parser.evaluateSelectorAsync({
unscopedSelector: expr.value,
terminal: undefined as unknown as ITerminal,
context: context
}))
];
}

private async _evaluateSimpleSelector(expr: string, context: string): Promise<RushConfigurationProject[]> {
const index: number = expr.indexOf(':');

if (index === -1) {
return this._evaluateDetailedSelector({ scope: 'name', value: expr }, context);
}

return this._evaluateDetailedSelector(
{
scope: expr.slice(0, index),
value: expr.slice(index + 1)
},
context
);
}
}
39 changes: 39 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressionJsonFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library';

import { SelectorExpression } from './SelectorExpressions';
import schemaJson from '../schemas/selector-expression.schema.json';

/**
* A utility class for saving and loading selector expression JSON files.
*/
export class SelectorExpressionJsonFile {
private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);

public static async loadAsync(jsonFilePath: string): Promise<SelectorExpression> {
const expr: SelectorExpression = await JsonFile.loadAndValidateAsync(
jsonFilePath,
SelectorExpressionJsonFile._jsonSchema
);
return expr;
}

public static async tryLoadAsync(jsonFilePath: string): Promise<SelectorExpression | undefined> {
try {
return await this.loadAsync(jsonFilePath);
} catch (error) {
if (FileSystem.isNotExistError(error as Error)) {
return undefined;
}
throw error;
}
}

public static loadFromString(jsonString: string): SelectorExpression {
const expr: SelectorExpression = JsonFile.parseString(jsonString);
SelectorExpressionJsonFile._jsonSchema.validateObject(expr, 'stdin');
return expr;
}
}
59 changes: 59 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

/**
* A "Selector Expression" is a JSON description of a complex selection of
* projects, using concepts familiar to users of the Rush CLI.
*/
export type SelectorExpression = ExpressionSelector | ExpressionParameter | ExpressionOperator;

export type ExpressionSelector = string | IExpressionDetailedSelector;

export interface IExpressionDetailedSelector {
scope: string;
value: string;

// Reserved for future use
filters?: Record<string, string>;
}

export type ExpressionParameter = {
[K in `--${string}`]: string;
};

export type ExpressionOperator = IExpressionOperatorAnd | IExpressionOperatorOr | IExpressionOperatorNot;

export interface IExpressionOperatorAnd {
and: SelectorExpression[];
}

export interface IExpressionOperatorOr {
or: SelectorExpression[];
}

export interface IExpressionOperatorNot {
not: SelectorExpression;
}

// A collection of type guards useful for interacting with selector expressions.

export function isDetailedSelector(expr: SelectorExpression): expr is IExpressionDetailedSelector {
return !!(expr && (expr as IExpressionDetailedSelector).scope);
}

export function isParameter(expr: SelectorExpression): expr is ExpressionParameter {
const keys: string[] = Object.keys(expr);
return keys.length === 1 && keys[0].startsWith('--');
}

export function isAnd(expr: SelectorExpression): expr is IExpressionOperatorAnd {
return !!(expr && (expr as IExpressionOperatorAnd).and);
}

export function isOr(expr: SelectorExpression): expr is IExpressionOperatorOr {
return !!(expr && (expr as IExpressionOperatorOr).or);
}

export function isNot(expr: SelectorExpression): expr is IExpressionOperatorNot {
return !!(expr && (expr as IExpressionOperatorNot).not);
}
69 changes: 69 additions & 0 deletions libraries/rush-lib/src/api/test/RushProjectSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfiguration } from '../RushConfiguration';
import { RushConfigurationProject } from '../RushConfigurationProject';
import { RushProjectSelector } from '../RushProjectSelector';

function createProjectSelector(): RushProjectSelector {
const rushJsonFile: string = `${__dirname}/repo/rush-pnpm.json`;
const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
const projectSelector: RushProjectSelector = new RushProjectSelector(rushConfig, {
gitSelectorParserOptions: {
includeExternalDependencies: true,
enableFiltering: false
}
});
return projectSelector;
}

describe(RushProjectSelector.name, () => {
describe(RushProjectSelector.prototype.selectExpression.name, () => {
it('treats a string as a project name', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression('project2');
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('selects a project using a detailed scope', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
scope: 'name',
value: 'project2'
});
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('selects several projects with an OR operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
or: ['project1', 'project2', 'project3']
});
expect(projects.map((project) => project.packageName)).toEqual(['project1', 'project2', 'project3']);
});

it('restricts a selection with an AND operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
and: [{ or: ['project1', 'project2', 'project3'] }, 'project2']
});
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('restricts a selection with a NOT operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
not: 'project3'
});
expect(projects.map((project) => project.packageName)).toEqual(['project1', 'project2']);
});

it('applies a parameter to a project', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
'--to': 'project1'
});
expect(projects.map((project) => project.packageName)).toEqual(['project1']);
});
});
});

0 comments on commit b66c79d

Please sign in to comment.