Implement generic identifier casing conventions.
AI-Assisted: yes AI-Tool: OpenAI Codex / gpt-5.4 xhigh
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# ---> Vim
|
||||
.*sw?
|
||||
*.un~
|
||||
Session.vim
|
||||
.netrwhist
|
||||
*~
|
||||
|
||||
# ---> Test binaries (exclude everything in the test directories except nim
|
||||
# source files)
|
||||
/tests/*
|
||||
!/tests/*.nim
|
||||
|
||||
.codex
|
||||
12
identcasing.nimble
Normal file
12
identcasing.nimble
Normal file
@@ -0,0 +1,12 @@
|
||||
# Package
|
||||
|
||||
version = "0.1.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Little library to convert between identifier casing conventions."
|
||||
license = "MIT"
|
||||
srcDir = "src"
|
||||
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.2.8"
|
||||
177
src/identcasing.nim
Normal file
177
src/identcasing.nim
Normal file
@@ -0,0 +1,177 @@
|
||||
import unicode
|
||||
|
||||
## Utilities for parsing and converting identifier casing styles.
|
||||
##
|
||||
## The library normalizes supported identifiers into lowercase words and then
|
||||
## renders those words into a target style.
|
||||
##
|
||||
## For camel-style parsing, digits stay attached to the preceding word. This
|
||||
## preserves common technical identifiers such as ``oauth2Client``,
|
||||
## ``utf8String``, and ``ipv6Address``.
|
||||
##
|
||||
## Because of that rule, delimited identifiers with standalone numeric
|
||||
## segments do not always round-trip through camel case. For example:
|
||||
## ``PBM-123`` -> ``pbm123`` -> ``PBM123``.
|
||||
|
||||
type
|
||||
CaseStyle* = enum
|
||||
upperSnakeCase,
|
||||
lowerSnakeCase,
|
||||
titleSnakeCase,
|
||||
lowerKebabCase,
|
||||
upperKebabCase,
|
||||
trainCase,
|
||||
dotCase,
|
||||
lowerCamelCase,
|
||||
pascalCase
|
||||
|
||||
WordTransform = enum
|
||||
lowerWordTransform,
|
||||
upperWordTransform,
|
||||
titleWordTransform
|
||||
|
||||
const
|
||||
headerCase* = trainCase
|
||||
|
||||
func isUpperish(rune: Rune): bool =
|
||||
rune.isUpper or rune.isTitle
|
||||
|
||||
func titleFirstRune(word: string): string =
|
||||
let normalized = word.toLower
|
||||
if normalized.len == 0:
|
||||
return ""
|
||||
|
||||
var byteIndex = 0
|
||||
var firstRune: Rune
|
||||
fastRuneAt(normalized, byteIndex, firstRune, true)
|
||||
|
||||
result = $firstRune.toUpper
|
||||
if byteIndex < normalized.len:
|
||||
result.add normalized[byteIndex .. ^1]
|
||||
|
||||
func transformWord(word: string, transform: WordTransform): string =
|
||||
case transform
|
||||
of lowerWordTransform:
|
||||
result = word.toLower
|
||||
of upperWordTransform:
|
||||
result = word.toUpper
|
||||
of titleWordTransform:
|
||||
result = titleFirstRune(word)
|
||||
|
||||
func addWord(words: var seq[string], word: string) =
|
||||
if word.len > 0:
|
||||
words.add word.toLower
|
||||
|
||||
func joinWords(
|
||||
words: openArray[string],
|
||||
separator: char,
|
||||
transform: WordTransform
|
||||
): string =
|
||||
for i, word in words:
|
||||
if i > 0:
|
||||
result.add separator
|
||||
result.add transformWord(word, transform)
|
||||
|
||||
func squashWords(words: openArray[string], transform: WordTransform): string =
|
||||
for word in words:
|
||||
result.add transformWord(word, transform)
|
||||
|
||||
func parseDelimitedWords(value: string, separator: Rune): seq[string] =
|
||||
for word in value.split(separator):
|
||||
result.addWord(word)
|
||||
|
||||
func startsNewCamelWord(runes: openArray[Rune], index: int): bool =
|
||||
if index == 0:
|
||||
return false
|
||||
|
||||
let current = runes[index]
|
||||
if not current.isUpperish:
|
||||
return false
|
||||
|
||||
let previous = runes[index - 1]
|
||||
if not previous.isUpperish:
|
||||
return true
|
||||
|
||||
if index + 1 < runes.len:
|
||||
let next = runes[index + 1]
|
||||
if next.isLower or next.isTitle:
|
||||
return true
|
||||
|
||||
result = false
|
||||
|
||||
func parseCamelWords(value: string): seq[string] =
|
||||
# Camel parsing only splits on upper/titlecase boundaries. Digit runs remain
|
||||
# attached to the preceding word so identifiers like "oauth2Client" render
|
||||
# as "oauth2-client" rather than "oauth-2-client".
|
||||
let runes = value.toRunes
|
||||
var current = newStringOfCap(value.len)
|
||||
|
||||
for i, rune in runes:
|
||||
if startsNewCamelWord(runes, i):
|
||||
result.addWord(current)
|
||||
current.setLen 0
|
||||
current.add rune
|
||||
|
||||
result.addWord(current)
|
||||
|
||||
func parseWords*(value: string, style: CaseStyle): seq[string] =
|
||||
## Parse a supported identifier into normalized lowercase words.
|
||||
##
|
||||
## For ``lowerCamelCase`` and ``pascalCase``, digits remain attached to the
|
||||
## preceding word.
|
||||
if value.len == 0:
|
||||
return @[]
|
||||
|
||||
case style
|
||||
of upperSnakeCase, lowerSnakeCase, titleSnakeCase:
|
||||
result = parseDelimitedWords(value, '_'.Rune)
|
||||
of lowerKebabCase, upperKebabCase, trainCase:
|
||||
result = parseDelimitedWords(value, '-'.Rune)
|
||||
of dotCase:
|
||||
result = parseDelimitedWords(value, '.'.Rune)
|
||||
of lowerCamelCase, pascalCase:
|
||||
result = parseCamelWords(value)
|
||||
|
||||
func renderWords*(words: openArray[string], style: CaseStyle): string =
|
||||
## Render normalized words into a supported identifier style.
|
||||
case style
|
||||
of upperSnakeCase:
|
||||
result = joinWords(words, '_', upperWordTransform)
|
||||
of lowerSnakeCase:
|
||||
result = joinWords(words, '_', lowerWordTransform)
|
||||
of titleSnakeCase:
|
||||
result = joinWords(words, '_', titleWordTransform)
|
||||
of lowerKebabCase:
|
||||
result = joinWords(words, '-', lowerWordTransform)
|
||||
of upperKebabCase:
|
||||
result = joinWords(words, '-', upperWordTransform)
|
||||
of trainCase:
|
||||
result = joinWords(words, '-', titleWordTransform)
|
||||
of dotCase:
|
||||
result = joinWords(words, '.', lowerWordTransform)
|
||||
of lowerCamelCase:
|
||||
if words.len == 0:
|
||||
return ""
|
||||
|
||||
result = transformWord(words[0], lowerWordTransform)
|
||||
for i in 1 ..< words.len:
|
||||
result.add transformWord(words[i], titleWordTransform)
|
||||
of pascalCase:
|
||||
result = squashWords(words, titleWordTransform)
|
||||
|
||||
func convertCase*(
|
||||
value: string,
|
||||
sourceStyle: CaseStyle,
|
||||
targetStyle: CaseStyle
|
||||
): string =
|
||||
## Convert an identifier from one supported style to another.
|
||||
##
|
||||
## Round-tripping through camel case is not guaranteed when a delimited input
|
||||
## uses standalone numeric segments, such as ``PBM-123``.
|
||||
renderWords(parseWords(value, sourceStyle), targetStyle)
|
||||
|
||||
func lowerKebabCaseToLowerCamelCase*(str: string): string =
|
||||
convertCase(str, lowerKebabCase, lowerCamelCase)
|
||||
|
||||
func lowerCamelCaseToLowerKebabCase*(str: string): string =
|
||||
convertCase(str, lowerCamelCase, lowerKebabCase)
|
||||
1
tests/config.nims
Normal file
1
tests/config.nims
Normal file
@@ -0,0 +1 @@
|
||||
switch("path", "$projectDir/../src")
|
||||
61
tests/tidentcasing.nim
Normal file
61
tests/tidentcasing.nim
Normal file
@@ -0,0 +1,61 @@
|
||||
import unittest
|
||||
|
||||
import identcasing
|
||||
|
||||
let canonicalWords = @["naïve", "api", "value"]
|
||||
|
||||
suite "identifier casing":
|
||||
test "renders every supported style":
|
||||
check renderWords(canonicalWords, upperSnakeCase) == "NAÏVE_API_VALUE"
|
||||
check renderWords(canonicalWords, lowerSnakeCase) == "naïve_api_value"
|
||||
check renderWords(canonicalWords, titleSnakeCase) == "Naïve_Api_Value"
|
||||
check renderWords(canonicalWords, lowerKebabCase) == "naïve-api-value"
|
||||
check renderWords(canonicalWords, upperKebabCase) == "NAÏVE-API-VALUE"
|
||||
check renderWords(canonicalWords, trainCase) == "Naïve-Api-Value"
|
||||
check renderWords(canonicalWords, headerCase) == "Naïve-Api-Value"
|
||||
check renderWords(canonicalWords, dotCase) == "naïve.api.value"
|
||||
check renderWords(canonicalWords, lowerCamelCase) == "naïveApiValue"
|
||||
check renderWords(canonicalWords, pascalCase) == "NaïveApiValue"
|
||||
|
||||
test "parses every unambiguous style":
|
||||
check parseWords("NAÏVE_API_VALUE", upperSnakeCase) == canonicalWords
|
||||
check parseWords("naïve_api_value", lowerSnakeCase) == canonicalWords
|
||||
check parseWords("Naïve_Api_Value", titleSnakeCase) == canonicalWords
|
||||
check parseWords("naïve-api-value", lowerKebabCase) == canonicalWords
|
||||
check parseWords("NAÏVE-API-VALUE", upperKebabCase) == canonicalWords
|
||||
check parseWords("Naïve-Api-Value", trainCase) == canonicalWords
|
||||
check parseWords("naïve.api.value", dotCase) == canonicalWords
|
||||
check parseWords("naïveApiValue", lowerCamelCase) == canonicalWords
|
||||
check parseWords("NaïveApiValue", pascalCase) == canonicalWords
|
||||
|
||||
test "splits acronym and digit boundaries in camel styles":
|
||||
check parseWords("URLValue", pascalCase) == @["url", "value"]
|
||||
check parseWords("version2Value", lowerCamelCase) == @["version2", "value"]
|
||||
check convertCase("URLValue", pascalCase, lowerKebabCase) == "url-value"
|
||||
check convertCase("version2Value", lowerCamelCase, lowerSnakeCase) == "version2_value"
|
||||
|
||||
test "keeps digits attached to the preceding camel word":
|
||||
check parseWords("oauth2Client", lowerCamelCase) == @["oauth2", "client"]
|
||||
check parseWords("ipv6Address", lowerCamelCase) == @["ipv6", "address"]
|
||||
check convertCase("oauth2Client", lowerCamelCase, lowerKebabCase) == "oauth2-client"
|
||||
check convertCase("ipv6Address", lowerCamelCase, lowerSnakeCase) == "ipv6_address"
|
||||
|
||||
test "does not round-trip standalone numeric segments through camel case":
|
||||
check convertCase("PBM-123", upperKebabCase, lowerCamelCase) == "pbm123"
|
||||
check convertCase("pbm123", lowerCamelCase, upperKebabCase) == "PBM123"
|
||||
|
||||
test "converts between parseable and rendered styles":
|
||||
check convertCase("naïveApiValue", lowerCamelCase, upperSnakeCase) == "NAÏVE_API_VALUE"
|
||||
check convertCase("NaïveApiValue", pascalCase, upperKebabCase) == "NAÏVE-API-VALUE"
|
||||
check convertCase("naïve_api_value", lowerSnakeCase, trainCase) == "Naïve-Api-Value"
|
||||
|
||||
test "supports the existing pairwise helpers":
|
||||
check lowerKebabCaseToLowerCamelCase("lower-kebab-case") == "lowerKebabCase"
|
||||
check lowerCamelCaseToLowerKebabCase("lowerCamelCase") == "lower-camel-case"
|
||||
check lowerKebabCaseToLowerCamelCase("lower-äbc") == "lowerÄbc"
|
||||
check lowerCamelCaseToLowerKebabCase("lowerÄbc") == "lower-äbc"
|
||||
|
||||
test "handles empty values":
|
||||
check parseWords("", lowerCamelCase) == newSeq[string]()
|
||||
check renderWords([], lowerCamelCase) == ""
|
||||
check convertCase("", lowerCamelCase, upperSnakeCase) == ""
|
||||
Reference in New Issue
Block a user