Repository: stoplightio/spectral (ts)

Issue:
Casing function
**User story.**
As a ruleset maintainer, I would like to have a built-in casing function I could make use of in my own rulesets.
For the time being, in order to enforce casing, you need to use `pattern` function and come up with a regular expression.

**Describe the solution you'd like**
We could make enforcing naming convention in Spectral easier by providing `casing` function with pre-defined set of regular expressions, such as:
- pascal case
- camel case
- kebab case
- snake case
- etc.

`casing` function could also take a custom regular expression, same as `pattern` does.

Here is an example rule definition:
```yaml
given: $.paths[*].[*].[*].operationId # I know it looks weird, it's not important though
then:
      function: casing
      functionOptions:
        case: pascal
```

**Additional context**
It's a complementary functionality to https://github.com/stoplightio/spectral/issues/405

Pull request description:
feat: casing function
Closes #564

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

Open question - what about numbers? Should they be allowed or not? I'd say no yet curious to hear your opinion.

Patch:
diff --git a/docs/reference/functions.md b/docs/reference/functions.md
index b614686b..89f39fbd 100644
--- a/docs/reference/functions.md
+++ b/docs/reference/functions.md
@@ -101,9 +101,45 @@ notMatch | if provided, value must _not_ match this regex | no
         notMatch: ".+\/$"
 ```
 
+## casing
+
+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
+
+Available types are: 
+
+| name   | sample         |
+|--------|----------------|
+| flat   | verylongname   |
+| camel  | veryLongName   |
+| pascal | VeryLongName   |
+| kebab  | very-long-name |
+| cobol  | VERY-LONG-NAME |
+| snake  | very_long_name |
+| macro  | VERY_LONG_NAME |
+
+<!-- title: example -->
+
+```yaml
+camel-case-name:
+  description: Name should camelCased.
+  type: style
+  given: "$.name"
+  then:
+    function: casing
+    functionOptions:
+      type: camel
+```
+
 ## schema
 
-Use JSON Schema (draft 7) to treat the contents of the $given JSON Path as a JSON instance.
+Use JSON Schema (draft 4, 6 or 7) to treat the contents of the $given JSON Path as a JSON instance.
 
 <!-- title: functionOptions -->
 
diff --git a/src/functions/__tests__/unreferencedReusableObject.ts b/src/functions/__tests__/unreferencedReusableObject.test.ts
similarity index 100%
rename from src/functions/__tests__/unreferencedReusableObject.ts
rename to src/functions/__tests__/unreferencedReusableObject.test.ts
diff --git a/src/functions/casing.ts b/src/functions/casing.ts
new file mode 100644
index 00000000..223391b1
--- /dev/null
+++ b/src/functions/casing.ts
@@ -0,0 +1,40 @@
+import { Dictionary } from '@stoplight/types';
+import { ICasingOptions, IFunction } from '../types';
+
+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]+)*$/,
+};
+
+export const casing: IFunction<ICasingOptions> = (targetVal, opts) => {
+  if (typeof targetVal !== 'string' || targetVal.length === 0) {
+    return;
+  }
+
+  const set = opts.disallowDigits ? CASES : CASES_WITH_DIGITS;
+
+  if (opts.type in set && !set[opts.type].test(targetVal)) {
+    return [
+      {
+        message: `must be ${opts.type} case`,
+      },
+    ];
+  }
+
+  return;
+};
diff --git a/src/functions/index.ts b/src/functions/index.ts
index 4fd2b93e..49c4dde4 100644
--- a/src/functions/index.ts
+++ b/src/functions/index.ts
@@ -1,5 +1,6 @@
 export const functions = {
   alphabetical: require('./alphabetical').alphabetical,
+  casing: require('./casing').casing,
   enumeration: require('./enumeration').enumeration,
   length: require('./length').length,
   pattern: require('./pattern').pattern,
diff --git a/src/types/enums.ts b/src/types/enums.ts
index 8de2842d..2b96bc23 100644
--- a/src/types/enums.ts
+++ b/src/types/enums.ts
@@ -5,6 +5,7 @@ export enum RuleType {
 
 export enum RuleFunction {
   ALPHABETICAL = 'alphabetical',
+  CASING = 'casing',
   LENGTH = 'length',
   PATTERN = 'pattern',
   ENUM = 'enumeration',
diff --git a/src/types/rule.ts b/src/types/rule.ts
index b4c91a62..c0625ba4 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -1,7 +1,7 @@
 import { DiagnosticSeverity } from '@stoplight/types';
 import { RuleFunction, RuleType } from './enums';
 
-export type Rule = IRule | TruthyRule | XorRule | LengthRule | AlphaRule | PatternRule | SchemaRule;
+export type Rule = IRule | TruthyRule | XorRule | LengthRule | AlphaRule | PatternRule | CasingRule | SchemaRule;
 
 export interface IRule<T = string, O = any> {
   type?: RuleType;
@@ -80,6 +80,12 @@ export interface IRulePatternOptions {
 }
 export type PatternRule = IRule<RuleFunction.PATTERN, IRulePatternOptions>;
 
+export interface ICasingOptions {
+  type: 'flat' | 'camel' | 'pascal' | 'kebab' | 'cobol' | 'snake' | 'macro';
+  disallowDigits?: boolean;
+}
+export type CasingRule = IRule<RuleFunction.CASING, ICasingOptions>;
+
 export interface ISchemaOptions {
   schema: object;
 }


Test patch:
diff --git a/src/functions/__tests__/casing.test.ts b/src/functions/__tests__/casing.test.ts
new file mode 100644
index 00000000..4ee5f111
--- /dev/null
+++ b/src/functions/__tests__/casing.test.ts
@@ -0,0 +1,221 @@
+import { ICasingOptions } from '../../types';
+import { casing } from '../casing';
+
+function runCasing(target: unknown, type: ICasingOptions['type'], disallowDigits?: boolean) {
+  return casing(
+    target,
+    { type, disallowDigits },
+    { given: ['$'] },
+    { given: null, original: null, resolved: {} as any },
+  );
+}
+
+describe('casing', () => {
+  test('given non-string target should return nothing', () => {
+    expect(runCasing(false, 'camel')).toBeUndefined();
+    expect(runCasing(1, 'camel')).toBeUndefined();
+  });
+
+  test('given empty string target should return nothing', () => {
+    expect(runCasing('', 'camel')).toBeUndefined();
+  });
+
+  test('given unknown case type should return nothing', () => {
+    expect(runCasing('2', 'foo' as any)).toBeUndefined();
+  });
+
+  describe('casing type', () => {
+    describe('flat', () => {
+      const invalid = ['foo_test', 'Foo', '123', '1d', 'foo-bar'];
+      const valid = ['foo', 'foobar'];
+      const validWithDigits = ['foo9bar', 'foo24baz', 'foo1'];
+
+      test.each(invalid)('should recognize invalid target %s', target => {
+        expect(runCasing(target, 'flat')).toEqual([{ message: 'must be flat case' }]);
+      });
+
+      test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'flat', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('camel', () => {
+      const invalid = ['foo_test', 'Foo', '1fooBarBaz', '123', 'foo-bar'];
+      const valid = ['foo', 'fooBar', 'fooBarBaz'];
+      const validWithDigits = ['foo1', 'foo24Bar', 'fooBar0Baz323'];
+
+      test.each(invalid)('should recognize invalid target %s', target => {
+        expect(runCasing(target, 'camel')).toEqual([{ message: 'must be camel case' }]);
+      });
+
+      test.each([valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'camel', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('pascal', () => {
+      const invalid = ['foo_test', '123', '1fooBarBaz', 'fooBarBaz1', 'fooBar', 'foo1', 'foo-bar'];
+      const valid = ['Foo', 'FooBar', 'FooBarBaz'];
+      const validWithDigits = ['Foo1', 'FooBarBaz1'];
+
+      test.each(invalid)('should recognize invalid target %s', target => {
+        expect(runCasing(target, 'pascal')).toEqual([{ message: 'must be pascal case' }]);
+      });
+
+      test.each([valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'pascal', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('kebab', () => {
+      const invalid = [
+        'foo_test',
+        'Foo1',
+        '123',
+        'fooBarBaz1',
+        'fooBar',
+        'foO',
+        'foo-baR',
+        '1foo-bar',
+        'foo--bar',
+        'foo-',
+        '-foo',
+      ];
+
+      const valid = ['foo', 'foo-bar', 'foo-bar-baz'];
+      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' }]);
+      });
+
+      test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'kebab', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('cobol', () => {
+      const invalid = ['foo_test', 'Foo1', '123', 'fooBarBaz1', 'FOo', 'FOO-BAr', 'FOO--BAR', 'FOO-', '-FOO'];
+      const valid = ['FOO', 'FOO-BAR', 'FOO-BAR-BAZ'];
+      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' }]);
+      });
+
+      test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'cobol', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('snake', () => {
+      const invalid = ['Foo1', '123', 'fooBarBaz1', 'FOo', 'FOO-BAR', 'foo__bar', '1foo_bar1', 'foo_', '_foo'];
+      const valid = ['foo', 'foo_bar', 'foo_bar_baz'];
+      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' }]);
+      });
+
+      test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'snake', true)).toBeUndefined();
+        });
+      });
+    });
+
+    describe('macro', () => {
+      const invalid = [
+        'foo_test',
+        'Foo1',
+        '123',
+        'fooBarBaz1',
+        'FOo',
+        'FOO-BAR',
+        'FO__BAR',
+        '1FOO_BAR1',
+        'FOO___BAR1',
+        'FOO_',
+        '_FOO',
+      ];
+      const valid = ['FOO', 'FOO_BAR', 'FOO_BAR_BAZ'];
+      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' }]);
+      });
+
+      test.each([...valid, ...validWithDigits])('should recognize valid target %s', target => {
+        expect(runCasing(target, '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' }]);
+        });
+
+        test.each(valid)('should recognize valid target %s', target => {
+          expect(runCasing(target, 'macro', true)).toBeUndefined();
+        });
+      });
+    });
+  });
+});