diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 56e3e22..70871a0 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -1,5 +1,4 @@ import 'package:change_case/change_case.dart'; -import 'package:spell_out_numbers/spell_out_numbers.dart'; /// Default prefix used for empty or invalid enum values. const defaultEnumPrefix = 'value'; @@ -89,6 +88,72 @@ const generatedClassTokens = { const Set allKeywords = {...dartKeywords, ...generatedClassTokens}; +/// Converts a number to its English word representation. +/// Supports numbers up to trillions. +String _numberToWords(int number) { + if (number == 0) return 'zero'; + + const ones = [ + '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', + 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', + 'sixteen', 'seventeen', 'eighteen', 'nineteen' + ]; + + const tens = [ + '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', + 'eighty', 'ninety' + ]; + + final result = []; + var remaining = number; + + if (remaining >= 1000000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000000)) + ..add('trillion'); + remaining %= 1000000000000; + } + + if (remaining >= 1000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000)) + ..add('billion'); + remaining %= 1000000000; + } + + if (remaining >= 1000000) { + result + ..add(_numberToWords(remaining ~/ 1000000)) + ..add('million'); + remaining %= 1000000; + } + + if (remaining >= 1000) { + result + ..add(_numberToWords(remaining ~/ 1000)) + ..add('thousand'); + remaining %= 1000; + } + + if (remaining >= 100) { + result + ..add(ones[remaining ~/ 100]) + ..add('hundred'); + remaining %= 100; + } + + if (remaining >= 20) { + result.add(tens[remaining ~/ 10]); + if (remaining % 10 != 0) { + result.add(ones[remaining % 10]); + } + } else if (remaining > 0) { + result.add(ones[remaining]); + } + + return result.join(' ').trim(); +} + /// Ensures a name is not a Dart keyword by adding a $ prefix if necessary. String ensureNotKeyword(String name) { if (allKeywords.contains(name.toCamelCase()) || @@ -98,80 +163,103 @@ String ensureNotKeyword(String name) { return name; } -/// Processes a part of a name, handling numbers and casing. -/// If [isFirstPart] is true, numbers at the start will be moved to the end. -({String processed, String? number}) processPart( - String part, { - required bool isFirstPart, -}) { - final processedPart = part.replaceAll(RegExp('[^a-zA-Z0-9]'), ''); - if (processedPart.isEmpty) return (processed: '', number: null); - - // Handle numbers differently for first part vs subsequent parts - if (isFirstPart) { - final numberMatch = RegExp(r'^(\d+)(.+)$').firstMatch(processedPart); +/// Splits text into tokens and normalizes each one. +String _normalizeText(String text, {bool preserveNumbers = false}) { + if (text.isEmpty) return ''; + + // Clean invalid characters but preserve separators for splitting + final cleaned = text.replaceAll(RegExp(r'[^a-zA-Z0-9_\-\s]'), ''); + + // Split on separators and case boundaries + final tokens = cleaned + .split(RegExp(r'[_\-\s]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')) + .where((token) => token.isNotEmpty) + .toList(); + + if (tokens.isEmpty) return ''; + + final result = []; + final numbersToAppend = []; + + for (var i = 0; i < tokens.length; i++) { + final token = tokens[i]; + final isFirst = i == 0; + + // Extract numbers from token + final numberMatch = + RegExp(r'^(\d+)(.*)$|^(.+?)(\d+)$').firstMatch(token); + + String textPart; + String? numberPart; + if (numberMatch != null) { - final number = numberMatch.group(1)!; - final rest = numberMatch.group(2)!; - return (processed: rest.toCamelCase(), number: number); + if (numberMatch.group(1) != null) { + // Leading number: 123abc + numberPart = numberMatch.group(1); + textPart = numberMatch.group(2) ?? ''; + } else { + // Trailing number: abc123 + textPart = numberMatch.group(3) ?? ''; + numberPart = numberMatch.group(4); + } + } else if (RegExp(r'^\d+$').hasMatch(token)) { + // Pure number + numberPart = token; + textPart = ''; + } else { + // No numbers + textPart = token; + numberPart = null; } - return (processed: processedPart.toCamelCase(), number: null); - } else { - final numberMatch = RegExp( - r'^(\d+)(.+)$|^(.+?)(\d+)$', - ).firstMatch(processedPart); - if (numberMatch != null) { - final leadingNumber = numberMatch.group(1); - final leadingRest = numberMatch.group(2); - final trailingBase = numberMatch.group(3); - final trailingNumber = numberMatch.group(4); - - if (leadingNumber != null && leadingRest != null) { - return (processed: leadingRest.toPascalCase(), number: leadingNumber); - } else if (trailingBase != null && trailingNumber != null) { - return (processed: trailingBase.toPascalCase(), number: trailingNumber); + + // Process text part + if (textPart.isNotEmpty) { + final normalized = _normalizeCasing(textPart, isFirst: isFirst); + result.add(normalized); + } + + // Handle numbers + if (numberPart != null) { + if (isFirst && textPart.isNotEmpty && + numberMatch?.group(1) != null) { + // Move leading numbers from first token to end + // (e.g., "1status" -> "status1") + numbersToAppend.add(numberPart); + } else { + // Keep numbers in place for trailing numbers or non-first tokens + result.add(numberPart); } } - return (processed: processedPart.toPascalCase(), number: null); } + + // Append any numbers that were moved from the first token + result.addAll(numbersToAppend); + + return result.join(); } -/// Splits a string into parts based on common separators and case boundaries. -List splitIntoParts(String value) => - value.split(RegExp(r'[_\- ]|(?=[A-Z])')); - -/// Processes parts into a normalized name. -String processPartsIntoName(List parts) { - if (parts.isEmpty) return ''; - - final processedParts = []; - - // Process first part - final firstResult = processPart(parts.first, isFirstPart: true); - if (firstResult.processed.isNotEmpty) { - processedParts.add(firstResult.processed); - if (firstResult.number != null) { - processedParts.add(firstResult.number!); - } +/// Normalizes the casing of a text token. +String _normalizeCasing(String text, {required bool isFirst}) { + if (text.isEmpty) return text; + + final isAllCaps = text == text.toUpperCase() && text != text.toLowerCase(); + + // Special handling for keywords - keep them lowercase for first part only + if (isFirst && allKeywords.contains(text.toLowerCase())) { + return text.toLowerCase(); } - - // Process remaining parts - for (var i = 1; i < parts.length; i++) { - final result = processPart(parts[i], isFirstPart: false); - if (result.processed.isNotEmpty) { - processedParts.add(result.processed); - if (result.number != null) { - processedParts.add(result.number!); - } - } + + if (isFirst) { + return isAllCaps ? text.toLowerCase() : text.toCamelCase(); + } else { + return isAllCaps ? text.toPascalCase() : text.toPascalCase(); } - - return processedParts.join(); } + + /// Normalizes a single name to follow Dart guidelines. String normalizeSingle(String name, {bool preserveNumbers = false}) { - // Handle empty or underscore-only strings if (name.isEmpty || RegExp(r'^_+$').hasMatch(name)) { return ''; } @@ -180,40 +268,43 @@ String normalizeSingle(String name, {bool preserveNumbers = false}) { var processedName = name.replaceAll(RegExp('^_+'), ''); if (processedName.isEmpty) return ''; - // If we need to preserve numbers and the name is just a number, return it + // If preserving numbers and it's just a number, return as-is if (preserveNumbers && RegExp(r'^\d+$').hasMatch(processedName)) { return processedName; } - final parts = splitIntoParts(processedName); - processedName = processPartsIntoName(parts); - - // If preserving numbers, ensure we don't lose them in the normalization - if (preserveNumbers) { - final originalNumber = RegExp(r'\d+$').firstMatch(name)?.group(0); - final processedNumber = RegExp(r'\d+$').firstMatch(processedName)?.group(0); - if (originalNumber != null && processedNumber != originalNumber) { - // Remove any trailing numbers and append the original number - final baseProcessed = processedName.replaceAll(RegExp(r'\d+$'), ''); - processedName = '$baseProcessed$originalNumber'; - } - } - + processedName = _normalizeText( + processedName, + preserveNumbers: preserveNumbers, + ); + return ensureNotKeyword(processedName); } /// Normalizes an enum value name, handling special cases like integers. String normalizeEnumValueName(String value) { - // For integer values, spell out the number - if (RegExp(r'^\d+$').hasMatch(value)) { + // Only spell out numbers if the entire value is just a number (no prefix) + if (RegExp(r'^-?\d+$').hasMatch(value)) { final number = int.parse(value); - final words = EnglishNumberScheme().toWord(number); + final words = number < 0 + ? 'minus ${_numberToWords(number.abs())}' + : _numberToWords(number); final normalized = normalizeSingle(words); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + return normalized.isEmpty + ? defaultEnumPrefix + : normalized.toCamelCase(); } - final normalized = normalizeSingle(value); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + // For values with prefixes (like ERROR_404), preserve numbers as-is + final normalized = normalizeSingle(value, preserveNumbers: true); + if (normalized.isEmpty) return defaultEnumPrefix; + + // Don't apply toCamelCase if the normalized value starts with $ + if (normalized.startsWith(r'$')) { + return normalized; + } + + return normalized.toCamelCase(); } /// Ensures uniqueness in a list of normalized names diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index ef81ba1..d7c644a 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: logging: ^1.3.0 meta: ^1.16.0 path: ^1.9.1 - spell_out_numbers: ^1.0.0 tonik_core: ^0.0.6 dev_dependencies: diff --git a/packages/tonik_generate/test/src/naming/name_utils_test.dart b/packages/tonik_generate/test/src/naming/name_utils_test.dart new file mode 100644 index 0000000..1109322 --- /dev/null +++ b/packages/tonik_generate/test/src/naming/name_utils_test.dart @@ -0,0 +1,154 @@ +import 'package:test/test.dart'; +import 'package:tonik_generate/src/naming/name_utils.dart'; + +void main() { + group('normalizeEnumValueName', () { + group('number conversion', () { + test('converts single digits to words', () { + expect(normalizeEnumValueName('0'), 'zero'); + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + expect(normalizeEnumValueName('9'), 'nine'); + }); + + test('converts teen numbers to words', () { + expect(normalizeEnumValueName('10'), 'ten'); + expect(normalizeEnumValueName('11'), 'eleven'); + expect(normalizeEnumValueName('15'), 'fifteen'); + expect(normalizeEnumValueName('19'), 'nineteen'); + }); + + test('converts larger numbers to exact expected output', () { + expect(normalizeEnumValueName('42'), 'fortyTwo'); + expect(normalizeEnumValueName('100'), 'oneHundred'); + expect(normalizeEnumValueName('123'), 'oneHundredTwentyThree'); + expect(normalizeEnumValueName('1000'), 'oneThousand'); + }); + + test('handles negative numbers with exact output', () { + expect(normalizeEnumValueName('-1'), 'minusOne'); + expect(normalizeEnumValueName('-42'), 'minusFortyTwo'); + expect(normalizeEnumValueName('-100'), 'minusOneHundred'); + expect(normalizeEnumValueName('-999'), 'minusNineHundredNinetyNine'); + }); + + test('converts millions to exact expected output', () { + expect(normalizeEnumValueName('1000000'), 'oneMillion'); + expect(normalizeEnumValueName('2000000'), 'twoMillion'); + expect(normalizeEnumValueName('5000000'), 'fiveMillion'); + expect(normalizeEnumValueName('1500000'), + 'oneMillionFiveHundredThousand'); + }); + + test('converts billions to exact expected output', () { + expect(normalizeEnumValueName('1000000000'), 'oneBillion'); + expect(normalizeEnumValueName('3000000000'), 'threeBillion'); + expect(normalizeEnumValueName('7000000000'), 'sevenBillion'); + expect(normalizeEnumValueName('1500000000'), + 'oneBillionFiveHundredMillion'); + }); + + test('converts trillions to exact expected output', () { + expect(normalizeEnumValueName('1000000000000'), 'oneTrillion'); + expect(normalizeEnumValueName('5000000000000'), 'fiveTrillion'); + expect(normalizeEnumValueName('9000000000000'), 'nineTrillion'); + expect(normalizeEnumValueName('1500000000000'), + 'oneTrillionFiveHundredBillion'); + }); + + test('handles complex large numbers', () { + expect(normalizeEnumValueName('1234567890'), + 'oneBillionTwoHundredThirtyFourMillion' + 'FiveHundredSixtySevenThousandEightHundredNinety'); + expect(normalizeEnumValueName('999999999999'), + 'nineHundredNinetyNineBillionNineHundredNinetyNineMillion' + 'NineHundredNinetyNineThousandNineHundredNinetyNine'); + }); + + test('produces camelCase identifiers', () { + // Based on existing test expectations + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + }); + + group('string normalization', () { + test('normalizes simple strings', () { + expect(normalizeEnumValueName('active'), 'active'); + expect(normalizeEnumValueName('inactive'), 'inactive'); + expect(normalizeEnumValueName('pending'), 'pending'); + }); + + test('handles case conversion properly', () { + expect(normalizeEnumValueName('ACTIVE'), 'active'); // Clean lowercase + expect(normalizeEnumValueName('InActive'), 'inActive'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + }); + + test('handles strings with separators', () { + expect(normalizeEnumValueName('in-progress'), 'inProgress'); + expect(normalizeEnumValueName('not_started'), 'notStarted'); + expect(normalizeEnumValueName('on hold'), 'onHold'); + }); + + test('handles mixed alphanumeric strings', () { + expect(normalizeEnumValueName('status1'), 'status1'); + expect( + normalizeEnumValueName('1status'), + 'status1', + ); // Number moved to end + expect(normalizeEnumValueName('v2_final'), 'v2Final'); + }); + + test('comprehensive real-world enum value cases', () { + // Common API status codes and enum patterns + expect(normalizeEnumValueName('SUCCESS_CODE'), 'successCode'); + expect(normalizeEnumValueName('ERROR_404'), 'error404'); + expect(normalizeEnumValueName('HTTP_STATUS'), 'httpStatus'); + expect(normalizeEnumValueName('NOT_FOUND'), 'notFound'); + expect(normalizeEnumValueName('API_VERSION_2'), 'apiVersion2'); + expect(normalizeEnumValueName('USER-ACCOUNT'), 'userAccount'); + expect(normalizeEnumValueName('data_model'), 'dataModel'); + expect(normalizeEnumValueName('ADMIN'), 'admin'); + expect(normalizeEnumValueName('guest'), 'guest'); + expect(normalizeEnumValueName('999'), 'nineHundredNinetyNine'); + expect(normalizeEnumValueName('2024'), 'twoThousandTwentyFour'); + }); + }); + + group('edge cases', () { + test('handles empty and invalid inputs', () { + expect(normalizeEnumValueName(''), 'value'); + expect(normalizeEnumValueName('_'), 'value'); + expect(normalizeEnumValueName('__'), 'value'); + }); + + test('handles special characters', () { + expect(normalizeEnumValueName('!@#'), 'value'); + expect(normalizeEnumValueName('status!'), 'status'); + expect(normalizeEnumValueName('test@#123'), 'test123'); + }); + + test('handles leading underscores', () { + expect(normalizeEnumValueName('_active'), 'active'); + expect(normalizeEnumValueName('__pending'), 'pending'); + }); + test('matches expected enum generation behavior', () { + // These are the expectations from the existing enum generator tests + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + + test('produces clean, readable identifiers', () { + // Common enum value patterns should be clean and readable + expect(normalizeEnumValueName('SUCCESS'), 'success'); + expect(normalizeEnumValueName('ERROR'), 'error'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + expect(normalizeEnumValueName('IN_PROGRESS'), 'inProgress'); + }); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 9d59be4..4625da4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: logging: ^1.3.0 meta: ^1.16.0 path: ^1.9.1 - spell_out_numbers: ^1.0.0 tonik: ^0.0.4 tonik_core: ^0.0.4 tonik_generate: ^0.0.4