Repository: stoplightio/spectral (ts)

Issue:
Casing function: allow hyphens or slashes
Casing for headers might need a Hyphenated-Pascal-Case and paths would need /slashSeperated/camelCase. Maybe we can get this into the casing function, through some sort of "separator" logic. 

https://github.com/adidas/api-guidelines/pull/22

Pull request:
feat: Introduce optional enhancers to casing function
Fixes #788
**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No





Patch:
diff --git a/docs/reference/functions.md b/docs/reference/functions.md
index 10b89cc2..76bfc128 100644
--- a/docs/reference/functions.md
+++ b/docs/reference/functions.md
@@ -107,10 +107,17 @@ Text must match a certain case, like `camelCase` or `snake_case`.
 
 <!-- title: functionOptions -->
 
-name | description | required?
----------|----------|---------
-type | the casing type to match against | yes
-disallowDigits | if not truthy, digits are allowed | no
+name                   | description                                             | required?
+-----------------------|---------------------------------------------------------|----------
+type                   | the casing type to match against                        | yes
+disallowDigits         | if not truthy, digits are allowed                       | no
+separator.char         | additional char to separate groups of words             | no
+separator.allowLeading | can the group separator char be used at the first char? | no
+
+**Note:** In advanced scenarios, `separator.char` and `separator.allowLeading` can be leveraged to validate certain naming conventions.
+For instance, the following naming style could be enforced:
+ - Headers _(eg. `X-YourMighty-Header`)_: type: `pascal`, separator.char: `-`
+ - Camel cased paths _(eg. `/path/toThe/amazingResource`)_: type: `camel`, separator.char: `/`, separator.allowLeading: `true`
 
 Available types are: 
 
diff --git a/src/functions/casing.ts b/src/functions/casing.ts
index 95cfee0e..073d93c0 100644
--- a/src/functions/casing.ts
+++ b/src/functions/casing.ts
@@ -1,31 +1,37 @@
 import { Dictionary } from '@stoplight/types';
+import { AssertionError } from 'assert';
+import { escapeRegExp } from 'lodash';
 import { IFunction, IRule, RuleFunction } from '../types';
 
+export enum CasingType {
+  flat = 'flat',
+  camel = 'camel',
+  pascal = 'pascal',
+  kebab = 'kebab',
+  cobol = 'cobol',
+  snake = 'snake',
+  macro = 'macro',
+}
+
 export interface ICasingOptions {
-  type: 'flat' | 'camel' | 'pascal' | 'kebab' | 'cobol' | 'snake' | 'macro';
+  type: CasingType;
   disallowDigits?: boolean;
+  separator?: {
+    char: string;
+    allowLeading?: boolean;
+  };
 }
 
 export type CasingRule = IRule<RuleFunction.CASING, ICasingOptions>;
 
-const CASES: Dictionary<RegExp, ICasingOptions['type']> = {
-  flat: /^[a-z]+$/,
-  camel: /^[a-z]+(?:[A-Z][a-z]+)*$/,
-  pascal: /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/,
-  kebab: /^[a-z]+(?:-[a-z]+)*$/,
-  cobol: /^[A-Z]+(?:-[A-Z]+)*$/,
-  snake: /^[a-z]+(?:_[a-z]+)*$/,
-  macro: /^[A-Z]+(?:_[A-Z]+)*$/,
-};
-
-const CASES_WITH_DIGITS: Dictionary<RegExp, ICasingOptions['type']> = {
-  flat: /^[a-z][a-z0-9]*$/,
-  camel: /^[a-z][a-z0-9]*(?:[A-Z0-9][a-z0-9]+)*$/,
-  pascal: /^[A-Z][a-z0-9]*(?:[A-Z0-9][a-z0-9]+)*$/,
-  kebab: /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/,
-  cobol: /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*$/,
-  snake: /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/,
-  macro: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/,
+const CASES: Dictionary<string, CasingType> = {
+  [CasingType.flat]: '[a-z][a-z{__DIGITS__}]*',
+  [CasingType.camel]: '[a-z][a-z{__DIGITS__}]*(?:[A-Z{__DIGITS__}][a-z{__DIGITS__}]+)*',
+  [CasingType.pascal]: '[A-Z][a-z{__DIGITS__}]*(?:[A-Z{__DIGITS__}][a-z{__DIGITS__}]+)*',
+  [CasingType.kebab]: '[a-z][a-z{__DIGITS__}]*(?:-[a-z{__DIGITS__}]+)*',
+  [CasingType.cobol]: '[A-Z][A-Z{__DIGITS__}]*(?:-[A-Z{__DIGITS__}]+)*',
+  [CasingType.snake]: '[a-z][a-z{__DIGITS__}]*(?:_[a-z{__DIGITS__}]+)*',
+  [CasingType.macro]: '[A-Z][A-Z{__DIGITS__}]*(?:_[A-Z{__DIGITS__}]+)*',
 };
 
 export const casing: IFunction<ICasingOptions> = (targetVal, opts) => {
@@ -33,15 +39,54 @@ export const casing: IFunction<ICasingOptions> = (targetVal, opts) => {
     return;
   }
 
-  const set = opts.disallowDigits ? CASES : CASES_WITH_DIGITS;
+  assertValidOptions(opts);
+
+  const casingValidator = buildFrom(CASES[opts.type], opts);
+
+  if (casingValidator.test(targetVal)) {
+    return;
+  }
+
+  return [
+    {
+      message: `must be ${opts.type} case`,
+    },
+  ];
+};
+
+function assertValidOptions(opts: ICasingOptions): asserts opts is ICasingOptions {
+  if (!(opts.type in CASES)) {
+    throw new AssertionError({ message: `Invalid '${opts.type}' type value.` });
+  }
+
+  if (opts.separator === undefined) {
+    return;
+  }
+
+  if (opts.separator.allowLeading !== undefined && opts.separator.char === undefined) {
+    throw new AssertionError({
+      message: "'separator.allowLeading' can only be valued when 'separator.char' is defined.",
+    });
+  }
+
+  if (opts.separator.char.length !== 1) {
+    throw new AssertionError({ message: "When valued, 'separator.char' should only be one character long." });
+  }
+}
+
+const DIGITS_PATTERN = '0-9';
 
-  if (opts.type in set && !set[opts.type].test(targetVal)) {
-    return [
-      {
-        message: `must be ${opts.type} case`,
-      },
-    ];
+const buildFrom = (basePattern: string, overrides: ICasingOptions): RegExp => {
+  const injectDigits = overrides.disallowDigits !== true;
+
+  const pattern = basePattern.replace(/\{__DIGITS__\}/g, injectDigits ? DIGITS_PATTERN : '');
+
+  if (overrides.separator === undefined) {
+    return new RegExp(`^${pattern}$`);
   }
 
-  return;
+  const separatorPattern = `[${escapeRegExp(overrides.separator.char)}]`;
+  const leadingSeparatorPattern = overrides.separator.allowLeading === true ? `${separatorPattern}?` : '';
+
+  return new RegExp(`^${leadingSeparatorPattern}${pattern}(?:${separatorPattern}${pattern})*$`);
 };


Test patch:
diff --git a/src/functions/__tests__/casing.test.ts b/src/functions/__tests__/casing.test.ts
index 6e367d65..b2de97aa 100644
--- a/src/functions/__tests__/casing.test.ts
+++ b/src/functions/__tests__/casing.test.ts
@@ -1,9 +1,10 @@
-import { casing, ICasingOptions } from '../casing';
+import { AssertionError } from 'assert';
+import { casing, CasingType } from '../casing';
 
-function runCasing(target: unknown, type: ICasingOptions['type'], disallowDigits?: boolean) {
+function runCasing(target: unknown, type: CasingType, disallowDigits?: boolean, separator?: any) {
   return casing(
     target,
-    { type, disallowDigits },
+    { type, disallowDigits, separator },
     { given: ['$'] },
     { given: null, original: null, resolved: {} as any },
   );
@@ -11,16 +12,16 @@ function runCasing(target: unknown, type: ICasingOptions['type'], disallowDigits
 
 describe('casing', () => {
   test('given non-string target should return nothing', () => {
-    expect(runCasing(false, 'camel')).toBeUndefined();
-    expect(runCasing(1, 'camel')).toBeUndefined();
+    expect(runCasing(false, CasingType.camel)).toBeUndefined();
+    expect(runCasing(1, CasingType.camel)).toBeUndefined();
   });
 
   test('given empty string target should return nothing', () => {
-    expect(runCasing('', 'camel')).toBeUndefined();
+    expect(runCasing('', CasingType.camel)).toBeUndefined();
   });
 
   test('given unknown case type should return nothing', () => {
-    expect(runCasing('2', 'foo' as any)).toBeUndefined();
+    expect(() => runCasing('2', 'foo' as any)).toThrowError(AssertionError);
   });
 
   describe('casing type', () => {
@@ -30,20 +31,20 @@ describe('casing', () => {
       const validWithDigits = ['foo9bar', 'foo24baz', 'foo1'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'flat')).toEqual([{ message: 'must be flat case' }]);
+        expect(runCasing(target, CasingType.flat)).toEqual([{ message: 'must be flat case' }]);
       });
 
       test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'flat')).toBeUndefined();
+        expect(runCasing(target, CasingType.flat)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'flat', true)).toEqual([{ message: 'must be flat case' }]);
+          expect(runCasing(target, CasingType.flat, true)).toEqual([{ message: 'must be flat case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'flat', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.flat, true)).toBeUndefined();
         });
       });
     });
@@ -54,20 +55,20 @@ describe('casing', () => {
       const validWithDigits = ['foo1', 'foo24Bar', 'fooBar0Baz323'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'camel')).toEqual([{ message: 'must be camel case' }]);
+        expect(runCasing(target, CasingType.camel)).toEqual([{ message: 'must be camel case' }]);
       });
 
       test.each([valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'camel')).toBeUndefined();
+        expect(runCasing(target, CasingType.camel)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'camel', true)).toEqual([{ message: 'must be camel case' }]);
+          expect(runCasing(target, CasingType.camel, true)).toEqual([{ message: 'must be camel case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'camel', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.camel, true)).toBeUndefined();
         });
       });
     });
@@ -78,20 +79,20 @@ describe('casing', () => {
       const validWithDigits = ['Foo1', 'FooBarBaz1'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'pascal')).toEqual([{ message: 'must be pascal case' }]);
+        expect(runCasing(target, CasingType.pascal)).toEqual([{ message: 'must be pascal case' }]);
       });
 
       test.each([valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'pascal')).toBeUndefined();
+        expect(runCasing(target, CasingType.pascal)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'pascal', true)).toEqual([{ message: 'must be pascal case' }]);
+          expect(runCasing(target, CasingType.pascal, true)).toEqual([{ message: 'must be pascal case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'pascal', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.pascal, true)).toBeUndefined();
         });
       });
     });
@@ -115,20 +116,20 @@ describe('casing', () => {
       const validWithDigits = ['foo-bar1', 'foo1-2bar'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'kebab')).toEqual([{ message: 'must be kebab case' }]);
+        expect(runCasing(target, CasingType.kebab)).toEqual([{ message: 'must be kebab case' }]);
       });
 
       test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'kebab')).toBeUndefined();
+        expect(runCasing(target, CasingType.kebab)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'kebab', true)).toEqual([{ message: 'must be kebab case' }]);
+          expect(runCasing(target, CasingType.kebab, true)).toEqual([{ message: 'must be kebab case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'kebab', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.kebab, true)).toBeUndefined();
         });
       });
     });
@@ -139,20 +140,20 @@ describe('casing', () => {
       const validWithDigits = ['FOO-BAR1', 'FOO2-3BAR1'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'cobol')).toEqual([{ message: 'must be cobol case' }]);
+        expect(runCasing(target, CasingType.cobol)).toEqual([{ message: 'must be cobol case' }]);
       });
 
       test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'cobol')).toBeUndefined();
+        expect(runCasing(target, CasingType.cobol)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'cobol', true)).toEqual([{ message: 'must be cobol case' }]);
+          expect(runCasing(target, CasingType.cobol, true)).toEqual([{ message: 'must be cobol case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'cobol', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.cobol, true)).toBeUndefined();
         });
       });
     });
@@ -163,20 +164,20 @@ describe('casing', () => {
       const validWithDigits = ['foo_bar1', 'foo2_4bar1'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'snake')).toEqual([{ message: 'must be snake case' }]);
+        expect(runCasing(target, CasingType.snake)).toEqual([{ message: 'must be snake case' }]);
       });
 
       test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'snake')).toBeUndefined();
+        expect(runCasing(target, CasingType.snake)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'snake', true)).toEqual([{ message: 'must be snake case' }]);
+          expect(runCasing(target, CasingType.snake, true)).toEqual([{ message: 'must be snake case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'snake', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.snake, true)).toBeUndefined();
         });
       });
     });
@@ -199,22 +200,114 @@ describe('casing', () => {
       const validWithDigits = ['FOO_BAR1', 'FOO2_4BAR1', 'FOO2_4_2'];
 
       test.each(invalid)('should recognize invalid target %s', target => {
-        expect(runCasing(target, 'macro')).toEqual([{ message: 'must be macro case' }]);
+        expect(runCasing(target, CasingType.macro)).toEqual([{ message: 'must be macro case' }]);
       });
 
       test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
-        expect(runCasing(target, 'macro')).toBeUndefined();
+        expect(runCasing(target, CasingType.macro)).toBeUndefined();
       });
 
       describe('when digits are disallowed', () => {
         test.each([...invalid, ...validWithDigits])('should recognize invalid target %s', target => {
-          expect(runCasing(target, 'macro', true)).toEqual([{ message: 'must be macro case' }]);
+          expect(runCasing(target, CasingType.macro, true)).toEqual([{ message: 'must be macro case' }]);
         });
 
         test.each(valid)('should recognize valid target %s', target => {
-          expect(runCasing(target, 'macro', true)).toBeUndefined();
+          expect(runCasing(target, CasingType.macro, true)).toBeUndefined();
         });
       });
     });
   });
+
+  describe('casing with custom separator configuration', () => {
+    describe('parameters validation', () => {
+      const casingTypes = Object.keys(CasingType);
+
+      test.each([...casingTypes])(
+        'with "%s" type - throws when allowLeadingSeparator option is used without specifying separator',
+        type => {
+          expect(() =>
+            runCasing('irrelevant value', CasingType[type], undefined, { char: undefined, allowLeading: true }),
+          ).toThrow(AssertionError);
+        },
+      );
+
+      const invalidSepLengthCombinations = Object.values(casingTypes).reduce<Array<[string, number]>>(
+        (combinations, type) => {
+          for (const len of [0, 2, 3, 17]) {
+            combinations.push([type, len]);
+          }
+
+          return combinations;
+        },
+        [],
+      );
+
+      test.each(invalidSepLengthCombinations)(
+        'with type "%s" - throws when separator value is "%i" (not exactly one char long)',
+        (type, len) => {
+          expect(() => runCasing('irrelevant value', CasingType[type], undefined, { char: '-'.repeat(len) })).toThrow(
+            AssertionError,
+          );
+        },
+      );
+    });
+
+    const baseData: Array<[string, string, string, string]> = [[CasingType.flat, 'flat', 'flat01', 'Nope']];
+
+    const testCases: Array<[string, boolean, string, boolean, string, string, string]> = [];
+    Object.values(baseData).forEach(data =>
+      ['/', '*', '-'].forEach(char =>
+        [true, false].forEach(allowLeading =>
+          [true, false].forEach(disallowDigits =>
+            testCases.push([data[0], disallowDigits, char, allowLeading, data[1], data[2], data[3]]),
+          ),
+        ),
+      ),
+    );
+
+    describe('properly detects valid cases', () => {
+      test.each(testCases)(
+        'with type "%s", disallowDigits: %s, separator: "%s", allowLeadingSeparator: %s',
+        (type, disallowDigits, char, allowLeading, simple, withDigits, invalid) => {
+          expect(
+            runCasing(`${simple}${char}${simple}`, CasingType[type], disallowDigits, { char, allowLeading }),
+          ).toBeUndefined();
+
+          expect(
+            runCasing(`${simple}${char}${invalid}`, CasingType[type], disallowDigits, { char, allowLeading }),
+          ).toEqual([{ message: `must be ${type} case` }]);
+
+          const digitsResults = runCasing(`${withDigits}${char}${simple}`, CasingType[type], disallowDigits, {
+            char,
+            allowLeading,
+          });
+
+          if (!disallowDigits) {
+            expect(digitsResults).toBeUndefined();
+          } else {
+            expect(digitsResults).toEqual([{ message: `must be ${type} case` }]);
+          }
+
+          const leadingSepResults = runCasing(`${char}${simple}${char}${simple}`, CasingType[type], disallowDigits, {
+            char,
+            allowLeading,
+          });
+
+          if (allowLeading) {
+            expect(leadingSepResults).toBeUndefined();
+          } else {
+            expect(leadingSepResults).toEqual([{ message: `must be ${type} case` }]);
+          }
+        },
+      );
+    });
+
+    test('allows advanced scenarios', () => {
+      expect(runCasing('X-MyAmazing-Header', CasingType.pascal, true, { char: '-' })).toBeUndefined();
+      expect(
+        runCasing('/path/to/myResource', CasingType.camel, true, { char: '/', allowLeading: true }),
+      ).toBeUndefined();
+    });
+  });
 });