Skip to content
Closed
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
4 changes: 2 additions & 2 deletions npm_modules/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async function getProjectName(argv: ArgumentsResolver<CommandParameters>): Promi
]);

projectName = result.projectName;
const validationError = validateProjectName(projectName);
const validationError = validateProjectName(projectName, { requireBazelModuleName: true });

if (validationError) {
console.log(wrapInColor(`\n❌ ${validationError}\n`, ANSI_COLORS.RED_COLOR));
Expand Down Expand Up @@ -306,7 +306,7 @@ async function valdiBootstrap(argv: ArgumentsResolver<CommandParameters>) {

// Validate project name if provided via command line argument
if (argv.getArgument('projectName')) {
const validationError = validateProjectName(projectName);
const validationError = validateProjectName(projectName, { requireBazelModuleName: true });
if (validationError) {
throw new CliError(validationError);
}
Expand Down
50 changes: 49 additions & 1 deletion npm_modules/cli/src/utils/stringUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'jasmine';
import { sanitizeProjectName, toPascalCase, toSnakeCase, validateProjectName } from './stringUtils';
import { isValidBazelModuleName, sanitizeProjectName, toPascalCase, toSnakeCase, validateProjectName } from './stringUtils';

describe('stringUtils', () => {
it('converts strings to pascal case', () => {
Expand Down Expand Up @@ -50,6 +50,24 @@ describe('stringUtils', () => {
});
});

describe('isValidBazelModuleName', () => {
it('accepts names Bazel accepts', () => {
expect(isValidBazelModuleName('myproject')).toBe(true);
expect(isValidBazelModuleName('my_project')).toBe(true);
expect(isValidBazelModuleName('my.cool-project123')).toBe(true);
expect(isValidBazelModuleName('a')).toBe(true);
});

it('rejects names Bazel rejects', () => {
expect(isValidBazelModuleName('MyProject')).toBe(false);
expect(isValidBazelModuleName('MY_PROJECT')).toBe(false);
expect(isValidBazelModuleName('_my_project')).toBe(false);
expect(isValidBazelModuleName('123project')).toBe(false);
expect(isValidBazelModuleName('my_project_')).toBe(false);
expect(isValidBazelModuleName('')).toBe(false);
});
});

describe('validateProjectName', () => {
it('rejects empty names', () => {
expect(validateProjectName('')).toBeTruthy();
Expand Down Expand Up @@ -91,6 +109,36 @@ describe('stringUtils', () => {
// so validateProjectName returns a warning about the change
expect(validateProjectName('123project')).toContain('sanitized');
});

describe('with requireBazelModuleName option', () => {
const opts = { requireBazelModuleName: true };

it('accepts valid Bazel module names', () => {
expect(validateProjectName('my_project', opts)).toBeNull();
expect(validateProjectName('myproject', opts)).toBeNull();
expect(validateProjectName('my-project', opts)).toBeNull();
expect(validateProjectName('project123', opts)).toBeNull();
});

it('rejects names that are not valid Bazel module names', () => {
// Regression: these used to pass validation and only fail once Bazel read the
// generated MODULE.bazel, i.e. after the project files had been written.
expect(validateProjectName('MyProject', opts)).toContain('not a valid Bazel module name');
expect(validateProjectName('testNewModule', opts)).toContain('not a valid Bazel module name');
expect(validateProjectName('MY_PROJECT', opts)).toContain('not a valid Bazel module name');
expect(validateProjectName('_', opts)).toContain('not a valid Bazel module name');
});

it('suggests a valid name when one can be derived', () => {
expect(validateProjectName('MyProject', opts)).toContain('Did you mean "myproject"?');
expect(validateProjectName('My-Project', opts)).toContain('Did you mean "my_project"?');
});

it('rejects names that start with numbers', () => {
// Bazel module names must begin with a lowercase letter
expect(validateProjectName('123project', opts)).toContain('not a valid Bazel module name');
});
});
});
});

38 changes: 36 additions & 2 deletions npm_modules/cli/src/utils/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,32 @@ export function sanitizeProjectName(name: string): string {
return sanitized;
}

/**
* The project name is used as the Bazel module name in `module(name = ...)` in
* MODULE.bazel, which Bazel requires to 1) only contain lowercase letters (a-z),
* digits (0-9), dots (.), hyphens (-) and underscores (_), 2) begin with a
* lowercase letter and 3) end with a lowercase letter or digit.
*/
const BAZEL_MODULE_NAME_REGEX = /^[a-z]([\d._a-z-]*[\da-z])?$/;

export function isValidBazelModuleName(name: string): boolean {
return BAZEL_MODULE_NAME_REGEX.test(name);
}

export interface ValidateProjectNameOptions {
/**
* Also require the sanitized name to be a valid Bazel module name. Only needed
* when the name becomes the `module(name = ...)` of a new MODULE.bazel (i.e.
* `valdi bootstrap`). Module and target names created by `valdi new_module`
* are case-sensitive directory/BUILD target names and do not need this.
*/
requireBazelModuleName?: boolean;
}

/**
* Validates a project name and returns an error message if invalid, or null if valid.
*/
export function validateProjectName(name: string): string | null {
export function validateProjectName(name: string, options: ValidateProjectNameOptions = {}): string | null {
if (!name || name.trim().length === 0) {
return 'Project name cannot be empty.';
}
Expand All @@ -79,7 +101,19 @@ export function validateProjectName(name: string): string | null {
if (RESERVED_PROJECT_NAMES.has(sanitized.toLowerCase())) {
return `Project name "${name}" (sanitized to "${sanitized}") is a reserved word and cannot be used. Please choose a different name.`;
}


// When the name is used as the Bazel module name in MODULE.bazel, reject anything
// Bazel would refuse before any files are written.
if (options.requireBazelModuleName && !isValidBazelModuleName(sanitized)) {
const suggestion = sanitized.toLowerCase().replace(/^[^a-z]+/, '').replace(/[^\da-z]+$/, '');
return (
`Project name "${name}" is not a valid Bazel module name. ` +
`It must only contain lowercase letters (a-z), digits (0-9), dots (.), hyphens (-) and underscores (_), ` +
`begin with a lowercase letter and end with a lowercase letter or digit.` +
(suggestion ? ` Did you mean "${suggestion}"?` : '')
);
}

// Warn if the name was significantly changed during sanitization
if (sanitized !== name.replace(/-/g, '_')) {
return `Project name "${name}" contains invalid characters. It will be sanitized to "${sanitized}". Please use only letters, numbers, underscores, and dashes.`;
Expand Down
Loading