From 5f8342355a856e5be7b8a7b851e1519d62678465 Mon Sep 17 00:00:00 2001 From: Denis Gribov Date: Thu, 13 Apr 2023 15:26:34 +0500 Subject: [PATCH] feat(rules): expand Latin-only characters limitation for `subject-case` with Unicode support (#3575) --- @commitlint/rules/src/subject-case.test.ts | 140 ++++++++++++++++++++- @commitlint/rules/src/subject-case.ts | 19 ++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/@commitlint/rules/src/subject-case.test.ts b/@commitlint/rules/src/subject-case.test.ts index 55a6158e44..ac4b15d769 100644 --- a/@commitlint/rules/src/subject-case.test.ts +++ b/@commitlint/rules/src/subject-case.test.ts @@ -5,26 +5,44 @@ const messages = { empty: 'test:\n', numeric: 'test: 1.0.0', lowercase: 'test: subject', + lowercase_unicode: 'test: тема', // Bulgarian for `subject` mixedcase: 'test: sUbJeCt', uppercase: 'test: SUBJECT', + uppercase_unicode: 'test: ÛNDERWERP', // Frisian for `SUBJECT` camelcase: 'test: subJect', + camelcase_unicode: 'test: θέΜα', // Greek for `subJect` kebabcase: 'test: sub-ject', + kebabcase_unicode: 'test: áb-har', // Irish for `sub-ject` pascalcase: 'test: SubJect', + pascalcase_unicode: 'test: ТақыРып', // Kazakh for `SubJect` snakecase: 'test: sub_ject', + snakecase_unicode: 'test: сэ_дэв', // Mongolian for `sub_ject` startcase: 'test: Sub Ject', + startcase_unicode: 'test: Äm Ne', // Swedish for `Sub Ject` + sentencecase: 'test: Sub ject', + sentencecase_unicode: 'test: Мав зуъ', // Tajik for `Sub ject` }; const parsed = { empty: parse(messages.empty), numeric: parse(messages.numeric), lowercase: parse(messages.lowercase), + lowercase_unicode: parse(messages.lowercase_unicode), mixedcase: parse(messages.mixedcase), uppercase: parse(messages.uppercase), + uppercase_unicode: parse(messages.uppercase_unicode), camelcase: parse(messages.camelcase), + camelcase_unicode: parse(messages.camelcase_unicode), kebabcase: parse(messages.kebabcase), + kebabcase_unicode: parse(messages.kebabcase_unicode), pascalcase: parse(messages.pascalcase), + pascalcase_unicode: parse(messages.pascalcase_unicode), snakecase: parse(messages.snakecase), + snakecase_unicode: parse(messages.snakecase_unicode), startcase: parse(messages.startcase), + startcase_unicode: parse(messages.startcase_unicode), + sentencecase: parse(messages.sentencecase), + sentencecase_unicode: parse(messages.sentencecase_unicode), }; test('with empty subject should succeed for "never lowercase"', async () => { @@ -63,6 +81,16 @@ test('with lowercase subject should succeed for "always lowercase"', async () => expect(actual).toEqual(expected); }); +test('with lowercase unicode subject should fail for "always uppercase"', async () => { + const [actual] = subjectCase( + await parsed.lowercase_unicode, + 'always', + 'upper-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('with mixedcase subject should succeed for "never lowercase"', async () => { const [actual] = subjectCase(await parsed.mixedcase, 'never', 'lowercase'); const expected = true; @@ -93,12 +121,22 @@ test('with uppercase subject should fail for "never uppercase"', async () => { expect(actual).toEqual(expected); }); -test('with lowercase subject should succeed for "always uppercase"', async () => { +test('with uppercase subject should succeed for "always uppercase"', async () => { const [actual] = subjectCase(await parsed.uppercase, 'always', 'uppercase'); const expected = true; expect(actual).toEqual(expected); }); +test('with uppercase unicode subject should fail for "always lowercase"', async () => { + const [actual] = subjectCase( + await parsed.uppercase_unicode, + 'always', + 'lower-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('with camelcase subject should fail for "always uppercase"', async () => { const [actual] = subjectCase(await parsed.camelcase, 'always', 'uppercase'); const expected = false; @@ -135,6 +173,26 @@ test('with camelcase subject should succeed for "always camelcase"', async () => expect(actual).toEqual(expected); }); +test('with camelcase unicode subject should fail for "always sentencecase"', async () => { + const [actual] = subjectCase( + await parsed.camelcase_unicode, + 'always', + 'sentence-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + +test('with kebabcase unicode subject should fail for "always camelcase"', async () => { + const [actual] = subjectCase( + await parsed.kebabcase_unicode, + 'always', + 'camel-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('with pascalcase subject should fail for "always uppercase"', async () => { const [actual] = subjectCase(await parsed.pascalcase, 'always', 'uppercase'); const expected = false; @@ -175,6 +233,16 @@ test('with pascalcase subject should fail for "always camelcase"', async () => { expect(actual).toEqual(expected); }); +test('with pascalcase unicode subject should fail for "always uppercase"', async () => { + const [actual] = subjectCase( + await parsed.pascalcase_unicode, + 'always', + 'upper-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('with snakecase subject should fail for "always uppercase"', async () => { const [actual] = subjectCase(await parsed.snakecase, 'always', 'uppercase'); const expected = false; @@ -211,6 +279,16 @@ test('with snakecase subject should fail for "always camelcase"', async () => { expect(actual).toEqual(expected); }); +test('with snakecase unicode subject should fail for "never lowercase"', async () => { + const [actual] = subjectCase( + await parsed.snakecase_unicode, + 'never', + 'lower-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('with startcase subject should fail for "always uppercase"', async () => { const [actual] = subjectCase(await parsed.startcase, 'always', 'uppercase'); const expected = false; @@ -253,6 +331,66 @@ test('with startcase subject should succeed for "always startcase"', async () => expect(actual).toEqual(expected); }); +test('with startcase unicode subject should fail for "always pascalcase"', async () => { + const [actual] = subjectCase( + await parsed.startcase_unicode, + 'always', + 'pascal-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + +test('with sentencecase subject should succeed for "always sentence-case"', async () => { + const [actual] = subjectCase( + await parsed.sentencecase, + 'always', + 'sentence-case' + ); + const expected = true; + expect(actual).toEqual(expected); +}); + +test('with sentencecase subject should fail for "never sentencecase"', async () => { + const [actual] = subjectCase( + await parsed.sentencecase, + 'never', + 'sentence-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + +test('with sentencecase subject should fail for "always pascalcase"', async () => { + const [actual] = subjectCase( + await parsed.sentencecase, + 'always', + 'pascal-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + +test('with sentencecase subject should succeed for "never camelcase"', async () => { + const [actual] = subjectCase( + await parsed.sentencecase, + 'never', + 'camel-case' + ); + const expected = true; + expect(actual).toEqual(expected); +}); + +test('with sentencecase unicode subject should fail for "always camelcase"', async () => { + const [actual] = subjectCase( + await parsed.sentencecase_unicode, + 'always', + 'camel-case' + ); + const expected = false; + expect(actual).toEqual(expected); +}); + test('should use expected message with "always"', async () => { const [, message] = subjectCase( await parsed.uppercase, diff --git a/@commitlint/rules/src/subject-case.ts b/@commitlint/rules/src/subject-case.ts index a993af5af1..1b66bce291 100644 --- a/@commitlint/rules/src/subject-case.ts +++ b/@commitlint/rules/src/subject-case.ts @@ -2,6 +2,23 @@ import {case as ensureCase} from '@commitlint/ensure'; import message from '@commitlint/message'; import {TargetCaseType, SyncRule} from '@commitlint/types'; +/** + * Since the rule requires first symbol of a subject to be a letter, use + * combination of Unicode `Cased_Letter` and `Other_Letter` categories now to + * allow non-Latin alphabets as well. + * + * Do not use `Letter` category directly to avoid capturing `Modifier_Letter` + * (which just modifiers letters, so we probably shouldn't anyway) and to stay + * close to previous implementation. + * + * Also, typescript does not seem to support almost any longhand category name + * (and even short for `Cased_Letter` too) so list all required letter + * categories manually just to prevent it from complaining about unknown stuff. + * + * @see [Unicode Categories]{@link https://www.regular-expressions.info/unicode.html} + */ +const startsWithLetterRegex = /^[\p{Ll}\p{Lu}\p{Lt}\p{Lo}]/iu; + const negated = (when?: string) => when === 'never'; export const subjectCase: SyncRule = ( @@ -11,7 +28,7 @@ export const subjectCase: SyncRule = ( ) => { const {subject} = parsed; - if (typeof subject !== 'string' || !subject.match(/^[a-z]/i)) { + if (typeof subject !== 'string' || !subject.match(startsWithLetterRegex)) { return [true]; }