diff --git a/changelog.md b/changelog.md index 9f01a0d58..d7a3df80d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## [0.24.10] + +- Add `String.CamelCase` to convert a string to camelCase. +- Add `String.PascalCase` to convert a string to PascalCase. +- Add `String.SnakeCase` to convert a string to snake_case. +- Add `String.KebabCase` to convert a string to kebab-case. +- Add `String.ConstantCase` to convert a string to CONSTANT_CASE. +- Reify `String.Capitalize` to a value-level function. +- Add `String.Words` to split a string into words. +- Add `String.IsUppercaseLetter` to check if a string is uppercase. +- Add `String.IsLowercaseLetter` to check if a string is lowercase. +- Add `String.IsDigit` to check if a string is a digit. + ## [0.24.9] - Reify `NaturalNumber.Add` to a value-level function. diff --git a/src/string/camel-case.test.ts b/src/string/camel-case.test.ts new file mode 100644 index 000000000..2bdeba00c --- /dev/null +++ b/src/string/camel-case.test.ts @@ -0,0 +1,57 @@ +import { $, Test, String } from '..' + +type CamelCase_Spec = [ + /** + * Can convert a string to camelCase. + */ + Test.Expect<$, 'helloWorld'>, + + /** + * Can convert a string with multiple words. + */ + Test.Expect<$, 'helloWorld42'>, + + /** + * Can convert a string with acronyms. + */ + Test.Expect<$, 'xmlHttpRequest'>, + + /** + * Can convert a string with numbers. + */ + Test.Expect<$, 'hello42World'>, + + /** + * Can convert a string with a mix of words and numbers. + */ + Test.Expect<$, 'hello42World42'>, + + /** + * Can handle an empty string. + */ + Test.Expect<$, ''> +] + +it('should convert a string to camelCase', () => { + expect(String.camelCase('hello world')).toBe('helloWorld') +}) + +it('should convert a string with multiple words', () => { + expect(String.camelCase('hello world 42')).toBe('helloWorld42') +}) + +it('should convert a string with acronyms', () => { + expect(String.camelCase('XMLHttpRequest')).toBe('xmlHttpRequest') +}) + +it('should convert a string with numbers', () => { + expect(String.camelCase('hello42world')).toBe('hello42World') +}) + +it('should convert a string with a mix of words and numbers', () => { + expect(String.camelCase('hello42world 42')).toBe('hello42World42') +}) + +it('should handle an empty string', () => { + expect(String.camelCase('')).toBe('') +}) diff --git a/src/string/camel-case.ts b/src/string/camel-case.ts new file mode 100644 index 000000000..c6b4c85bd --- /dev/null +++ b/src/string/camel-case.ts @@ -0,0 +1,65 @@ +import { Kind, Type, String } from '..' + +/** + * `_$camelCase` is a type-level function that takes in a string `S` and returns + * a new string in the "camelCase" format, whereby the first letter of each word + * is capitalized, except for the first word. Capitalized acronyms in the input + * are identified. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$camelCase<'hello world'>; // 'helloWorld' + * ``` + */ +export type _$camelCase< + S extends string, + WORDS extends string[] = String._$words +> = String._$fromList<{ + [K in keyof WORDS]: K extends '0' + ? String._$toLower + : String._$capitalize> +}> + +/** + * `CamelCase` is a type-level function that takes in a string `S` and returns + * a new string in the "camelCase" format, whereby the first letter of each word + * is capitalized, except for the first word. Capitalized acronyms in the input + * are identified. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // 'helloWorld' + * ``` + */ +export interface CamelCase extends Kind.Kind { + f(x: Type._$cast): _$camelCase +} + +/** + * Given a string, return a new string in the "camelCase" format, whereby the + * first letter of each word is capitalized, except for the first word. + * + * @param {string} x - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.camelCase('hello world') + * // ^? 'helloWorld' + * ``` + */ +export const camelCase = ((x: string) => + String.words(x) + .map((word, i) => + i === 0 ? word.toLowerCase() : String.capitalize(word.toLowerCase()) + ) + .join('')) as Kind._$reify diff --git a/src/string/capitalize.spec.ts b/src/string/capitalize.test.ts similarity index 62% rename from src/string/capitalize.spec.ts rename to src/string/capitalize.test.ts index f72e147a6..13f4df290 100644 --- a/src/string/capitalize.spec.ts +++ b/src/string/capitalize.test.ts @@ -1,4 +1,4 @@ -import { $, Test, String } from 'hkt-toolbelt' +import { $, Test, String } from '..' type Capitalize_Spec = [ /** @@ -28,3 +28,15 @@ type Capitalize_Spec = [ // @ts-expect-error $<$, number> ] + +it('should capitalize a string', () => { + expect(String.capitalize('hello')).toBe('Hello') +}) + +it('should capitalize a string with first character already capitalized', () => { + expect(String.capitalize('Hello')).toBe('Hello') +}) + +it('should handle empty string input', () => { + expect(String.capitalize('')).toBe('') +}) diff --git a/src/string/capitalize.ts b/src/string/capitalize.ts index 5bc5abbb9..c6dd14020 100644 --- a/src/string/capitalize.ts +++ b/src/string/capitalize.ts @@ -1,4 +1,4 @@ -import { Kind, Type } from '..' +import _, { Kind, Type } from '..' /** * `String._$capitalize` is a type-level function that capitalizes the first character of a string. @@ -27,3 +27,19 @@ interface _Capitalize extends Kind.Kind { } export { _Capitalize as Capitalize } + +/** + * Given a string, return a new string with the first letter capitalized. + * + * @param {string} x - The string to capitalize. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.capitalize('hello') + * // ^? 'Hello' + * ``` + */ +export const capitalize = ((x: string) => + x.charAt(0).toUpperCase() + x.slice(1)) as Kind._$reify<_Capitalize> diff --git a/src/string/constant-case.test.ts b/src/string/constant-case.test.ts new file mode 100644 index 000000000..52108b683 --- /dev/null +++ b/src/string/constant-case.test.ts @@ -0,0 +1,43 @@ +import { $, Test, String } from '..' + +type ConstantCase_Spec = [ + /** + * Can convert a string to CONSTANT_CASE. + */ + Test.Expect<$, 'HELLO_WORLD'>, + + /** + * Can convert a string with multiple words. + */ + Test.Expect<$, 'HELLO_WORLD_42'>, + + /** + * Can convert a string with acronyms. + */ + Test.Expect<$, 'XML_HTTP_REQUEST'>, + + /** + * Can convert a string with numbers. + */ + Test.Expect<$, 'HELLO_42_WORLD'> +] + +it('should convert a string to CONSTANT_CASE', () => { + expect(String.constantCase('hello world')).toBe('HELLO_WORLD') +}) + +it('should convert a string with multiple words', () => { + expect(String.constantCase('hello world 42')).toBe('HELLO_WORLD_42') +}) + +it('should convert a string with acronyms', () => { + expect(String.constantCase('XMLHttpRequest')).toBe('XML_HTTP_REQUEST') +}) + +it('should convert a string with numbers', () => { + expect(String.constantCase('hello42world')).toBe('HELLO_42_WORLD') +}) + +it('should handle an empty string', () => { + expect(String.constantCase('')).toBe('') +}) diff --git a/src/string/constant-case.ts b/src/string/constant-case.ts new file mode 100644 index 000000000..a28a4ded2 --- /dev/null +++ b/src/string/constant-case.ts @@ -0,0 +1,62 @@ +import { Kind, Type, String as String_ } from '..' + +/** + * `_$constantCase` is a type-level function that takes in a string `S` and returns + * a new string in the "CONSTANT_CASE" format, whereby each word is uppercased + * and separated by underscores. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$constantCase<'hello world'>; // 'HELLO_WORLD' + * ``` + */ +export type _$constantCase< + S extends string, + WORDS extends string[] = String_._$words +> = String_._$join< + { + [K in keyof WORDS]: String_._$toUpper + }, + '_' +> + +/** + * `ConstantCase` is a type-level function that takes in a string `S` and returns + * a new string in the "CONSTANT_CASE" format, whereby each word is uppercased + * and separated by underscores. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // 'HELLO_WORLD' + * ``` + */ +export interface ConstantCase extends Kind.Kind { + f(x: Type._$cast): _$constantCase +} + +/** + * Given a string, return a new string in the "CONSTANT_CASE" format, whereby + * each word is uppercased and separated by underscores. + * + * @param {string} x - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.constantCase('hello world') + * // ^? 'HELLO_WORLD' + * ``` + */ +export const constantCase = ((x: string) => + String_.words(x) + .map((word) => word.toUpperCase()) + .join('_')) as Kind._$reify diff --git a/src/string/index.ts b/src/string/index.ts index 60e762a67..3d5d412de 100644 --- a/src/string/index.ts +++ b/src/string/index.ts @@ -1,5 +1,7 @@ export * from './append' +export * from './camel-case' export * from './capitalize' +export * from './constant-case' export * from './ends-with' export * from './entries' export * from './first' @@ -7,18 +9,24 @@ export * from './from-char-code' export * from './from-list' export * from './includes' export * from './init' +export * from './is-digit' export * from './is-letter' +export * from './is-lowercase-letter' export * from './is-string' export * from './is-template' +export * from './is-uppercase-letter' export * from './join' +export * from './kebab-case' export * from './last' export * from './length' export * from './pad-end' export * from './pad-start' +export * from './pascal-case' export * from './prepend' export * from './replace' export * from './reverse' export * from './slice' +export * from './snake-case' export * from './split' export * from './starts-with' export * from './tail' @@ -26,3 +34,4 @@ export * from './to-char-code' export * from './to-list' export * from './to-lower' export * from './to-upper' +export * from './words' diff --git a/src/string/is-digit.test.ts b/src/string/is-digit.test.ts new file mode 100644 index 000000000..e2582cb89 --- /dev/null +++ b/src/string/is-digit.test.ts @@ -0,0 +1,45 @@ +import { $, String, Test } from '..' + +type IsDigit_Spec = [ + /** + * Can check if a string is a digit. + */ + Test.Expect<$, true>, + + /** + * Can check if a string is a digit + */ + Test.Expect<$, true>, + + /** + * Can check if a string is not a digit. + */ + Test.Expect<$, false>, + + /** + * An empty string is not a digit. + */ + Test.Expect<$, false>, + + /** + * A general string is not a digit. + */ + Test.Expect<$, false>, + + /** + * A template literal string is not a digit. + */ + Test.Expect<$, false> +] + +it('should check if a string is a digit', () => { + expect(String.isDigit('0')).toBe(true) +}) + +it('returns false for an empty string', () => { + expect(String.isDigit('')).toBe(false) +}) + +it('returns false for a general string', () => { + expect(String.isDigit('000')).toBe(false) +}) diff --git a/src/string/is-digit.ts b/src/string/is-digit.ts new file mode 100644 index 000000000..4a494f909 --- /dev/null +++ b/src/string/is-digit.ts @@ -0,0 +1,52 @@ +import { Kind, Type } from '..' + +type _$digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' + +/** + * `_$isDigit` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is a digit. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$isDigit<'0'>; // true + * ``` + */ +export type _$isDigit = S extends _$digit ? true : false + +/** + * `IsDigit` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is a digit. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // true + * ``` + */ +export interface IsDigit extends Kind.Kind { + f(x: Type._$cast): _$isDigit +} + +/** + * Given a string, return a boolean indicating whether the string is a + * digit. + * + * @param {string} x - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.isDigit('0') + * // ^? true + * ``` + */ +export const isDigit = ((x: string) => + x.match(/^[0-9]$/) !== null) as Kind._$reify diff --git a/src/string/is-lowercase-letter.test.ts b/src/string/is-lowercase-letter.test.ts new file mode 100644 index 000000000..cca9592c9 --- /dev/null +++ b/src/string/is-lowercase-letter.test.ts @@ -0,0 +1,40 @@ +import { $, String, Test } from '..' + +type IsLowercaseLetter_Spec = [ + /** + * Can check if a string is a lowercase letter. + */ + Test.Expect<$, true>, + + /** + * Can check if a string is not a lowercase letter. + */ + Test.Expect<$, false>, + + /** + * An empty string is not a lowercase letter. + */ + Test.Expect<$, false>, + + /** + * A general string is not a lowercase letter. + */ + Test.Expect<$, false>, + + /** + * A template literal string is not a lowercase letter. + */ + Test.Expect<$, false> +] + +it('should check if a string is a lowercase letter', () => { + expect(String.isLowercaseLetter('a')).toBe(true) +}) + +it('returns false for an empty string', () => { + expect(String.isLowercaseLetter('')).toBe(false) +}) + +it('returns false for a general string', () => { + expect(String.isLowercaseLetter('FOO')).toBe(false) +}) diff --git a/src/string/is-lowercase-letter.ts b/src/string/is-lowercase-letter.ts new file mode 100644 index 000000000..443bf9f30 --- /dev/null +++ b/src/string/is-lowercase-letter.ts @@ -0,0 +1,80 @@ +import { Kind, Type } from '..' + +type _$lowercaseLetter = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + +/** + * `_$isLowercaseLetter` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is a lowercase letter. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$isLowercaseLetter<'a'>; // true + * ``` + */ +export type _$isLowercaseLetter = S extends _$lowercaseLetter + ? true + : false + +/** + * `IsLowercaseLetter` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is a lowercase letter. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // true + * ``` + */ +export interface IsLowercaseLetter extends Kind.Kind { + f(x: Type._$cast): _$isLowercaseLetter +} + +/** + * Given a string, return a boolean indicating whether the string is a + * lowercase letter. + * + * @param {string} x - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.isLowercaseLetter('a') + * // ^? true + * ``` + */ +export const isLowercaseLetter = ((x: string) => + x.match(/^[a-z]$/) !== null) as Kind._$reify diff --git a/src/string/is-uppercase-letter.test.ts b/src/string/is-uppercase-letter.test.ts new file mode 100644 index 000000000..1adff0391 --- /dev/null +++ b/src/string/is-uppercase-letter.test.ts @@ -0,0 +1,40 @@ +import { $, String, Test } from '..' + +type IsUppercaseLetter_Spec = [ + /** + * Can check if a string is an uppercase letter. + */ + Test.Expect<$, true>, + + /** + * Can check if a string is not an uppercase letter. + */ + Test.Expect<$, false>, + + /** + * An empty string is not an uppercase letter. + */ + Test.Expect<$, false>, + + /** + * A general string is not an uppercase letter. + */ + Test.Expect<$, false>, + + /** + * A template literal string is not an uppercase letter. + */ + Test.Expect<$, false> +] + +it('should check if a string is an uppercase letter', () => { + expect(String.isUppercaseLetter('A')).toBe(true) +}) + +it('returns false for an empty string', () => { + expect(String.isUppercaseLetter('')).toBe(false) +}) + +it('returns false for a general string', () => { + expect(String.isUppercaseLetter('foo')).toBe(false) +}) diff --git a/src/string/is-uppercase-letter.ts b/src/string/is-uppercase-letter.ts new file mode 100644 index 000000000..3ff6aff66 --- /dev/null +++ b/src/string/is-uppercase-letter.ts @@ -0,0 +1,80 @@ +import { Kind, Type } from '..' + +type _$uppercaseLetter = + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + +/** + * `_$isUppercaseLetter` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is an uppercase letter. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$isUppercaseLetter<'A'>; // true + * ``` + */ +export type _$isUppercaseLetter = S extends _$uppercaseLetter + ? true + : false + +/** + * `IsUppercaseLetter` is a type-level function that takes in a string `S` and + * returns a boolean indicating whether the string is an uppercase letter. + * + * @template {string} S - The string to check. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // true + * ``` + */ +export interface IsUppercaseLetter extends Kind.Kind { + f(x: Type._$cast): _$isUppercaseLetter +} + +/** + * Given a string, return a boolean indicating whether the string is an + * uppercase letter. + * + * @param {string} x - The string to check. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.isUppercaseLetter('A') + * // ^? true + * ``` + */ +export const isUppercaseLetter = ((x: string) => + x.match(/^[A-Z]$/) !== null) as Kind._$reify diff --git a/src/string/kebab-case.test.ts b/src/string/kebab-case.test.ts new file mode 100644 index 000000000..4964f57fe --- /dev/null +++ b/src/string/kebab-case.test.ts @@ -0,0 +1,43 @@ +import { $, Test, String } from '..' + +type KebabCase_Spec = [ + /** + * Can convert a string to kebab-case. + */ + Test.Expect<$, 'hello-world'>, + + /** + * Can convert a string with multiple words. + */ + Test.Expect<$, 'hello-world-42'>, + + /** + * Can convert a string with acronyms. + */ + Test.Expect<$, 'xml-http-request'>, + + /** + * Can convert a string with numbers. + */ + Test.Expect<$, 'hello-42-world'> +] + +it('should convert a string to kebab-case', () => { + expect(String.kebabCase('hello world')).toBe('hello-world') +}) + +it('should convert a string with multiple words', () => { + expect(String.kebabCase('hello world 42')).toBe('hello-world-42') +}) + +it('should convert a string with acronyms', () => { + expect(String.kebabCase('XMLHttpRequest')).toBe('xml-http-request') +}) + +it('should convert a string with numbers', () => { + expect(String.kebabCase('hello42world')).toBe('hello-42-world') +}) + +it('should handle an empty string', () => { + expect(String.kebabCase('')).toBe('') +}) diff --git a/src/string/kebab-case.ts b/src/string/kebab-case.ts new file mode 100644 index 000000000..9facceda0 --- /dev/null +++ b/src/string/kebab-case.ts @@ -0,0 +1,62 @@ +import { Kind, Type, String as String_ } from '..' + +/** + * `_$kebabCase` is a type-level function that takes in a string `S` and returns + * a new string in the "kebab-case" format, whereby each word is lowercased and + * separated by hyphens. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$kebabCase<'hello world'>; // 'hello-world' + * ``` + */ +export type _$kebabCase< + S extends string, + WORDS extends string[] = String_._$words +> = String_._$join< + { + [K in keyof WORDS]: String_._$toLower + }, + '-' +> + +/** + * `KebabCase` is a type-level function that takes in a string `S` and returns + * a new string in the "kebab-case" format, whereby each word is lowercased and + * separated by hyphens. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // 'hello-world' + * ``` + */ +export interface KebabCase extends Kind.Kind { + f(x: Type._$cast): _$kebabCase +} + +/** + * Given a string, return a new string in the "kebab-case" format, whereby each + * word is lowercased and separated by hyphens. + * + * @param {string} x - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.kebabCase('hello world') + * // ^? 'hello-world' + * ``` + */ +export const kebabCase = ((x: string) => + String_.words(x) + .map((word) => word.toLowerCase()) + .join('-')) as Kind._$reify diff --git a/src/string/pascal-case.test.ts b/src/string/pascal-case.test.ts new file mode 100644 index 000000000..446090ea6 --- /dev/null +++ b/src/string/pascal-case.test.ts @@ -0,0 +1,43 @@ +import { $, Test, String } from '..' + +type PascalCase_Spec = [ + /** + * Can convert a string to PascalCase. + */ + Test.Expect<$, 'HelloWorld'>, + + /** + * Can convert a string with multiple words. + */ + Test.Expect<$, 'HelloWorld42'>, + + /** + * Can convert a string with acronyms. + */ + Test.Expect<$, 'XmlHttpRequest'>, + + /** + * Can convert a string with numbers. + */ + Test.Expect<$, 'Hello42World'> +] + +it('should convert a string to PascalCase', () => { + expect(String.pascalCase('hello world')).toBe('HelloWorld') +}) + +it('should convert a string with multiple words', () => { + expect(String.pascalCase('hello world 42')).toBe('HelloWorld42') +}) + +it('should convert a string with acronyms', () => { + expect(String.pascalCase('XMLHttpRequest')).toBe('XmlHttpRequest') +}) + +it('should convert a string with numbers', () => { + expect(String.pascalCase('hello42world')).toBe('Hello42World') +}) + +it('should handle an empty string', () => { + expect(String.pascalCase('')).toBe('') +}) diff --git a/src/string/pascal-case.ts b/src/string/pascal-case.ts new file mode 100644 index 000000000..49e23e464 --- /dev/null +++ b/src/string/pascal-case.ts @@ -0,0 +1,57 @@ +import { Kind, Type, String as String_ } from '..' + +/** + * `_$pascalCase` is a type-level function that takes in a string `S` and returns + * a new string in the "PascalCase" format. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$pascalCase<'hello world'>; // 'HelloWorld' + * ``` + */ +export type _$pascalCase< + S extends string, + WORDS extends string[] = String_._$words +> = String_._$fromList<{ + [K in keyof WORDS]: String_._$capitalize> +}> + +/** + * `PascalCase` is a type-level function that takes in a string `S` and returns + * a new string in the "PascalCase" format. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // 'HelloWorld' + * ``` + */ +export interface PascalCase extends Kind.Kind { + f(x: Type._$cast): _$pascalCase +} + +/** + * Given a string, return a new string in the "PascalCase" format, whereby the + * first letter of each word is capitalized. + * + * @param {string} x - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.pascalCase('hello world') + * // ^? 'HelloWorld' + * ``` + */ +export const pascalCase = ((x: string) => + String_.words(x) + .map((word) => String_.capitalize(word.toLowerCase())) + .join('')) as Kind._$reify diff --git a/src/string/snake-case.test.ts b/src/string/snake-case.test.ts new file mode 100644 index 000000000..9f799dc57 --- /dev/null +++ b/src/string/snake-case.test.ts @@ -0,0 +1,43 @@ +import { $, Test, String } from '..' + +type SnakeCase_Spec = [ + /** + * Can convert a string to snake_case. + */ + Test.Expect<$, 'hello_world'>, + + /** + * Can convert a string with multiple words. + */ + Test.Expect<$, 'hello_world_42'>, + + /** + * Can convert a string with acronyms. + */ + Test.Expect<$, 'xml_http_request'>, + + /** + * Can convert a string with numbers. + */ + Test.Expect<$, 'hello_42_world'> +] + +it('should convert a string to snake_case', () => { + expect(String.snakeCase('hello world')).toBe('hello_world') +}) + +it('should convert a string with multiple words', () => { + expect(String.snakeCase('hello world 42')).toBe('hello_world_42') +}) + +it('should convert a string with acronyms', () => { + expect(String.snakeCase('XMLHttpRequest')).toBe('xml_http_request') +}) + +it('should convert a string with numbers', () => { + expect(String.snakeCase('hello42world')).toBe('hello_42_world') +}) + +it('should handle an empty string', () => { + expect(String.snakeCase('')).toBe('') +}) diff --git a/src/string/snake-case.ts b/src/string/snake-case.ts new file mode 100644 index 000000000..ba8f42818 --- /dev/null +++ b/src/string/snake-case.ts @@ -0,0 +1,62 @@ +import { Kind, Type, String as String_ } from '..' + +/** + * `_$snakeCase` is a type-level function that takes in a string `S` and returns + * a new string in the "snake_case" format, whereby each word is lowercased and + * separated by underscores. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$snakeCase<'hello world'>; // 'hello_world' + * ``` + */ +export type _$snakeCase< + S extends string, + WORDS extends string[] = String_._$words +> = String_._$join< + { + [K in keyof WORDS]: String_._$toLower + }, + '_' +> + +/** + * `SnakeCase` is a type-level function that takes in a string `S` and returns + * a new string in the "snake_case" format, whereby each word is lowercased and + * separated by underscores. + * + * @template {string} S - The string to convert. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // 'hello_world' + * ``` + */ +export interface SnakeCase extends Kind.Kind { + f(x: Type._$cast): _$snakeCase +} + +/** + * Given a string, return a new string in the "snake_case" format, whereby each + * word is lowercased and separated by underscores. + * + * @param {string} x - The string to convert. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.snakeCase('hello world') + * // ^? 'hello_world' + * ``` + */ +export const snakeCase = ((x: string) => + String_.words(x) + .map((word) => word.toLowerCase()) + .join('_')) as Kind._$reify diff --git a/src/string/words.test.ts b/src/string/words.test.ts new file mode 100644 index 000000000..c3664f098 --- /dev/null +++ b/src/string/words.test.ts @@ -0,0 +1,54 @@ +import { $, String, Test } from '..' + +type Words_Spec = [ + /** + * Can split a string into words. + */ + Test.Expect<$, ['hello', 'world']>, + + /** + * Can split a string with digits + */ + Test.Expect<$, ['hello', '42', 'world']>, + + /** + * Can split a string into words with multiple spaces. + */ + Test.Expect<$, ['foo', 'bar']>, + + /** + * Can properly handle acronyms. + */ + Test.Expect<$, ['XML', 'Http', 'Request']>, + + /** + * Can handle alphanumeric input with no delimiters. + */ + Test.Expect<$, ['hello', '42', 'world']>, + + /** + * Splitting a generic string results in a `string[]` type. + */ + Test.Expect<$, string[]>, + + /** + * Splitting a template literal string results in a `string[]` type. + */ + Test.Expect<$, string[]> +] + +it('should split a string into words', () => { + expect(String.words('hello world')).toEqual(['hello', 'world']) +}) + +it('should split a string with digits', () => { + expect(String.words('hello42world')).toEqual(['hello', '42', 'world']) +}) + +it('should split a string into words with multiple spaces', () => { + expect(String.words('foo bar')).toEqual(['foo', 'bar']) +}) + +it('should properly handle acronyms', () => { + expect(String.words('XMLHttpRequest')).toEqual(['XML', 'Http', 'Request']) +}) diff --git a/src/string/words.ts b/src/string/words.ts new file mode 100644 index 000000000..36a889259 --- /dev/null +++ b/src/string/words.ts @@ -0,0 +1,311 @@ +import { String, Type, Kind } from '..' + +type _$isDelimiter = S extends ' ' | '-' | '_' ? true : false + +type _$words2< + S extends string, + /** + * The current word being built up as we iterate through the string, + * represented as a list of characters. + */ + CURRENT_WORD extends string[] = [], + /** + * The list of words currently identified. + */ + WORDS extends unknown[] = [], + /** + * The previous character in the string. + */ + PREV_CHAR extends string = '' +> = S extends `${infer Head}${infer Tail}` + ? _$isDelimiter extends true + ? /** + * The current character is a delimiter, so check if the current word is + * empty. + */ + CURRENT_WORD['length'] extends 0 + ? /** + * If the character is a delimiter, and the current word is empty, skip + * the current character. + */ + _$words2 + : /** + * If the character is a delimiter, and the current word is not empty, + * add the current word to the list of words and start a new empty word. + */ + _$words2], Head> + : /** + * The character is not a delimiter, so perform additional checks. + */ + String._$isUppercaseLetter extends true + ? String._$isUppercaseLetter extends true + ? /** + * If both the current character and the previous character are + * uppercase, we need to look ahead to see if the next character is + * lowercase. If it is, we treat the current character as the start of + * a new word. + */ + Tail extends `${infer Next}${string}` + ? String._$isLowercaseLetter extends true + ? /** + * Since the next character is lowercase, treat the current + * character as the start of a new word. + */ + CURRENT_WORD['length'] extends 0 + ? /** + * If the current word is empty, add the current character to + * the current word. + */ + _$words2 + : /** + * If the current word is not empty, add the current word to + * the list of words and start a new empty word. + */ + _$words2< + Tail, + [Head], + [...WORDS, String._$fromList], + Head + > + : /** + * Otherwise, the next character is not lowercase, so we can defer + * handling to the next iteration. + */ + _$words2 + : /** + * If both this character and the prior character was uppercase, + * and there is no next character, we can defer handling to the + * next iteration. + */ + _$words2 + : /** + * If the previous character is lowercase, then we are transitioning + * from lowercase to uppercase, which implies the start of a new word. + */ + String._$isLowercaseLetter extends true + ? CURRENT_WORD['length'] extends 0 + ? /** + * If the current word is empty, add the current character to the + * current word. + */ + _$words2 + : /** + * If the current word is not empty, add the current word to the + * list of words and start a new empty word. + */ + _$words2< + Tail, + [Head], + [...WORDS, String._$fromList], + Head + > + : /** + * If the previous character was not lowercase, and the current + * character is uppercase, then we include the current character in + * the current word. + */ + _$words2 + : /** + * If the current character isn't uppercase, we check if it's a digit. + * If it is, it is treated as a separate word from any adjacent letter + * characters. + */ + String._$isDigit extends true + ? /** + * Because the prior character might also be a digit, we check if it + * is. If it is a prior digit, then the current character should be + * appended to that word. + */ + String._$isDigit extends true + ? _$words2 + : CURRENT_WORD['length'] extends 0 + ? /** + * If the current word is empty, add the current character to the + * a new word. + */ + _$words2 + : /** + * If the current word is not empty, add the current word to the + * list of words and start a new empty word. + */ + _$words2< + Tail, + [Head], + [...WORDS, String._$fromList], + Head + > + : /** + * If the current character is not uppercase or a digit, then we treat + * it as part of the current word as long as the previous character + * was not a digit. + */ + String._$isDigit extends true + ? /** + * If the previous character was a digit, and the current character + * isn't, then we need to start a new word. + */ + CURRENT_WORD['length'] extends 0 + ? /** + * If the current word is empty, add the current character to the + * current word. + */ + _$words2 + : /** + * If the current word is not empty, add the current word to the + * list of words and start a new empty word. + */ + _$words2< + Tail, + [Head], + [...WORDS, String._$fromList], + Head + > + : _$words2 + : /** + * If we've iterated through all of the characters, return the list of + * collected words. + */ + CURRENT_WORD['length'] extends 0 + ? WORDS + : [...WORDS, String._$fromList] + +/** + * `_$words` is a type-level function that takes in a string `S` and returns + * a list of words in the string. Words are defined as sequences of characters + * separated by whitespace, or delimited by case changes. Acronyms in the input + * are identified as individual words. + * + * Consecutive digits are treated as their own word, separate from any adjacent + * letter characters. + * + * @template {string} S - The string to split. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * type Result = String._$words<'helloWorld'>; // ['hello', 'World'] + * type Result2 = String._$words<'hello world'>; // ['hello', 'world'] + * type Result3 = String._$words<'XMLHttpRequest'>; // ['XML', 'Http', 'Request'] + * ``` + */ +export type _$words = + String._$isTemplate extends true + ? string[] + : string extends S + ? string[] + : _$words2 + +/** + * `Words` is a type-level function that takes in a string `S` and returns + * a list of words in the string. Words are defined as sequences of characters + * separated by whitespace, or delimited by case changes. Acronyms in the input + * are identified as individual words. + * + * Consecutive digits are treated as their own word, separate from any adjacent + * letter characters. + * + * @template {string} S - The string to split. + * + * @example + * ```ts + * import { $, String } from "hkt-toolbelt"; + * + * type Result = $; // ['hello', 'World'] + * type Result2 = $; // ['hello', 'world'] + * type Result3 = $; // ['XML', 'Http', 'Request'] + * ``` + */ +export interface Words extends Kind.Kind { + f(x: Type._$cast): _$words +} + +const isUpperCase = (char: string) => char >= 'A' && char <= 'Z' + +const isLowerCase = (char: string) => char >= 'a' && char <= 'z' + +const isDigit = (char: string) => char >= '0' && char <= '9' + +const isDelimiter = (char: string) => + char === ' ' || char === '-' || char === '_' + +const isLetter = (char: string) => isUpperCase(char) || isLowerCase(char) + +/** + * Given a string, return a list of words in the string. + * + * @param {string} input - The string to split into words. + * + * @example + * ```ts + * import { String } from "hkt-toolbelt"; + * + * const result = String.words('hello42world') + * // ^? ['hello', '42', 'world'] + * ``` + */ +export const words = ((input: string) => { + const words: string[] = [] + let currentWord = '' + + for (let i = 0; i < input.length; i++) { + const char = input[i] + const prevChar = i > 0 ? input[i - 1] : '' + const nextChar = i < input.length - 1 ? input[i + 1] : '' + + if (isDelimiter(char)) { + if (currentWord.length > 0) { + words.push(currentWord) + currentWord = '' + } + continue + } + + const isCurrentDigit = isDigit(char) + const isPrevDigit = isDigit(prevChar) + const isCurrentUpper = isUpperCase(char) + const isPrevUpper = isUpperCase(prevChar) + const isPrevLower = isLowerCase(prevChar) + const isNextLower = isLowerCase(nextChar) + + if (isCurrentDigit) { + if (!isPrevDigit && currentWord.length > 0) { + // Transition from non-digit to digit, start new word + words.push(currentWord) + currentWord = char + } else { + currentWord += char + } + } else if (isLetter(char)) { + if (isPrevDigit && currentWord.length > 0) { + // Transition from digit to letter, start new word + words.push(currentWord) + currentWord = char + } else if ( + isCurrentUpper && + (isPrevLower || (isPrevUpper && isNextLower)) + ) { + // Transition from lowercase to uppercase or acronym ending + if (currentWord.length > 0) { + words.push(currentWord) + } + currentWord = char + } else { + currentWord += char + } + } else { + // Other characters (non-letter, non-digit, non-delimiter) + if (currentWord.length > 0) { + words.push(currentWord) + currentWord = '' + } + } + } + + // Add the last word if it's not empty + if (currentWord.length > 0) { + words.push(currentWord) + } + + return words +}) as Kind._$reify