Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NamedProjectSelectorParser } from '../../logic/selectors/NamedProjectSe
import { TagProjectSelectorParser } from '../../logic/selectors/TagProjectSelectorParser';
import { VersionPolicyProjectSelectorParser } from '../../logic/selectors/VersionPolicyProjectSelectorParser';
import { SubspaceSelectorParser } from '../../logic/selectors/SubspaceSelectorParser';
import { PathProjectSelectorParser } from '../../logic/selectors/PathProjectSelectorParser';
import { RushConstants } from '../../logic/RushConstants';
import type { Subspace } from '../../api/Subspace';

Expand Down Expand Up @@ -72,6 +73,7 @@ export class SelectionParameterSet {
selectorParsers.set('tag', new TagProjectSelectorParser(rushConfiguration));
selectorParsers.set('version-policy', new VersionPolicyProjectSelectorParser(rushConfiguration));
selectorParsers.set('subspace', new SubspaceSelectorParser(rushConfiguration));
selectorParsers.set('path', new PathProjectSelectorParser(rushConfiguration));

this._selectorParserByScope = selectorParsers;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as nodePath from 'node:path';

import { AlreadyReportedError } from '@rushstack/node-core-library';
import type { LookupByPath } from '@rushstack/lookup-by-path';

import type { RushConfiguration } from '../../api/RushConfiguration';
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
import type { IEvaluateSelectorOptions, ISelectorParser } from './ISelectorParser';
import { RushConstants } from '../RushConstants';

export class PathProjectSelectorParser implements ISelectorParser<RushConfigurationProject> {
private readonly _rushConfiguration: RushConfiguration;

public constructor(rushConfiguration: RushConfiguration) {
this._rushConfiguration = rushConfiguration;
}

public async evaluateSelectorAsync({
unscopedSelector,
terminal,
parameterName
}: IEvaluateSelectorOptions): Promise<Iterable<RushConfigurationProject>> {
// Resolve the input path against the working directory
const absolutePath: string = nodePath.resolve(process.cwd(), unscopedSelector);

// Relativize it to the rushJsonFolder
const relativePath: string = nodePath.relative(this._rushConfiguration.rushJsonFolder, absolutePath);

// Get the LookupByPath instance for the Rush root
const lookupByPath: LookupByPath<RushConfigurationProject> =
this._rushConfiguration.getProjectLookupForRoot(this._rushConfiguration.rushJsonFolder);

// Check if this path is within a project or matches a project exactly
const containingProject: RushConfigurationProject | undefined = lookupByPath.findChildPath(relativePath);

if (containingProject) {
return [containingProject];
}

// Check if there are any projects under this path (i.e., it's a directory containing projects)
const projectsUnderPath: Set<RushConfigurationProject> = new Set();
for (const [, project] of lookupByPath.entries(relativePath)) {
projectsUnderPath.add(project);
}

if (projectsUnderPath.size > 0) {
return projectsUnderPath;
}

// No projects found
terminal.writeErrorLine(
`The path "${unscopedSelector}" passed to "${parameterName}" does not match any project in ` +
`${RushConstants.rushJsonFilename}. The resolved path relative to the Rush root is "${relativePath}".`
);
throw new AlreadyReportedError();
}

public getCompletions(): Iterable<string> {
// Return empty completions as path completions are typically handled by the shell
return [];
}
}
195 changes: 195 additions & 0 deletions libraries/rush-lib/src/logic/selectors/test/SelectorParsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';

import { RushConfiguration } from '../../../api/RushConfiguration';
import { NamedProjectSelectorParser } from '../NamedProjectSelectorParser';
import { TagProjectSelectorParser } from '../TagProjectSelectorParser';
import { PathProjectSelectorParser } from '../PathProjectSelectorParser';

describe('SelectorParsers', () => {
let rushConfiguration: RushConfiguration;
let terminal: Terminal;
let terminalProvider: StringBufferTerminalProvider;

beforeEach(() => {
const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json');
rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
terminalProvider = new StringBufferTerminalProvider();
terminal = new Terminal(terminalProvider);
});

describe(NamedProjectSelectorParser.name, () => {
let parser: NamedProjectSelectorParser;

beforeEach(() => {
parser = new NamedProjectSelectorParser(rushConfiguration);
});

it('should select a project by exact package name', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should throw error for non-existent project', async () => {
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: 'nonexistent',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should provide completions for all projects', () => {
const completions = Array.from(parser.getCompletions());
expect(completions).toContain('project1');
expect(completions).toContain('project2');
expect(completions).toContain('project3');
});
});

describe(TagProjectSelectorParser.name, () => {
let parser: TagProjectSelectorParser;

beforeEach(() => {
parser = new TagProjectSelectorParser(rushConfiguration);
});

it('should return empty array for projects without tags', () => {
// The test fixture doesn't have tags configured, so this should return empty
const completions = Array.from(parser.getCompletions());
expect(completions).toHaveLength(0);
});

it('should throw error for non-existent tag', async () => {
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: 'nonexistent-tag',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});
});

describe(PathProjectSelectorParser.name, () => {
let parser: PathProjectSelectorParser;
const originalCwd = process.cwd();

beforeEach(() => {
parser = new PathProjectSelectorParser(rushConfiguration);
// Set cwd to the test repo root for consistent path resolution
process.chdir(rushConfiguration.rushJsonFolder);
});

afterEach(() => {
process.chdir(originalCwd);
});

it('should select a project by exact path', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should select a project by path within the project', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1/src/index.ts',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should select multiple projects from a parent directory', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: '.',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects.length).toBeGreaterThan(0);
// Should include all projects in the test repo
const packageNames = projects.map((p) => p.packageName).sort();
expect(packageNames).toContain('project1');
expect(packageNames).toContain('project2');
expect(packageNames).toContain('project3');
});

it('should select project from current directory when using dot', async () => {
// Change to project1 directory
process.chdir(path.join(rushConfiguration.rushJsonFolder, 'project1'));

const result = await parser.evaluateSelectorAsync({
unscopedSelector: '.',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should handle absolute paths', async () => {
const absolutePath = path.join(rushConfiguration.rushJsonFolder, 'project2');

const result = await parser.evaluateSelectorAsync({
unscopedSelector: absolutePath,
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project2');
});

it('should throw error for paths that do not match any project', async () => {
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: 'nonexistent/path',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should handle paths outside workspace', async () => {
// Paths outside the workspace should not match any project and throw
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: '../outside',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should return empty completions', () => {
const completions = Array.from(parser.getCompletions());
expect(completions).toHaveLength(0);
});
});
});
Loading