-
Notifications
You must be signed in to change notification settings - Fork 592
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
e67788f
commit b66c79d
Showing
15 changed files
with
575 additions
and
21 deletions.
There are no files selected for viewing
10 changes: 10 additions & 0 deletions
10
common/changes/@microsoft/rush/enelson-expressions_2023-07-30-19-26.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[]>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
69
libraries/rush-lib/src/api/test/RushProjectSelector.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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']); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.