diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a407f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Stubs +*_arginfo.h +vendor/ + +# Build products +*.so +*.lo +*.la +*.dep + +# Build directories +.libs/ +build/ + +# Configuration files +config.h +config.h.in +config.log +config.nice +config.status +configure +configure.ac + +# Makefiles +Makefile +Makefile.fragments +Makefile.objects + +# Autom4te cache +autom4te.cache/ + +# Libtool files +libtool + +# Test files +run-tests.php + +# IDE/Editor files +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + +# Editor backup files +*~ + +# PHPT test artifacts +tests/phpt/*.diff +tests/phpt/*.exp +tests/phpt/*.log +tests/phpt/*.out +tests/phpt/*.php +tests/phpt/*.sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0975e07 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,8 @@ +# Development + +## Dependencies + +- PHP 8.2+ +- [Composer](https://getcomposer.org) +- [Task](https://taskfile.dev) +- [Reflex](https://github.com/cespare/reflex) diff --git a/README.md b/README.md index bcc9a9f..f61184d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ -# template-ext -Modern PHP extension template - PestPHP + C unit tests ๐Ÿงช +# Template Extension + +**Template Extension** is a PHP extension template with string to_snake_case conversion functionality. + +## Features + +- `to_snake(string $input)` - Converts strings to snake_case format + +## Supported Conversions + +- camelCase โ†’ snake_case +- PascalCase โ†’ snake_case +- kebab-case โ†’ snake_case +- Space separated โ†’ snake_case +- Already snake_case โ†’ snake_case (unchanged) + +## Installation + +```bash +# Build and install the extension +phpize && ./configure --enable-template-ext +make +make install +``` + +## Usage + +```php + + + + + tests/php/ + + + + + + + + + + + + + + + + diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..227ee3d --- /dev/null +++ b/project.yml @@ -0,0 +1,159 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-26 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +--- +:project: + :name: "Template Extension" + + # how to use ceedling. If you're not sure, leave this as `gem` and `?` + :which_ceedling: gem + :ceedling_version: 1.1.2 + + # optional features. If you don't need them, keep them turned off for performance + :use_mocks: FALSE + :use_test_preprocessor: :all # options are :none, :mocks, :tests, or :all + :use_backtrace: :simple # options are :none, :simple, or :gdb + + # tweak the way ceedling handles automatic tasks + :build_root: build + :test_file_prefix: test_ + :default_tasks: + - test:all + + # performance options. If your tools start giving mysterious errors, consider + # dropping this to 1 to force single-tasking + :test_threads: 8 + :compile_threads: 8 + + # enable release build (more details in release_build section below) + :release_build: FALSE + +# Specify where to find mixins and any that should be enabled automatically +:mixins: + :enabled: [] + :load_paths: [] + +# further details to configure the way Ceedling handles test code +:test_build: + :use_assembly: FALSE + +# further details to configure the way Ceedling handles release code +:release_build: + :output: template_ext.out + :use_assembly: FALSE + :artifacts: [] + +# Plugins are optional Ceedling features which can be enabled. Ceedling supports +# a variety of plugins which may effect the way things are compiled, reported, +# or may provide new command options. Refer to the readme in each plugin for +# details on how to use it. +:plugins: + :load_paths: [] + :enabled: + - module_generator # handy for quickly creating source, header, and test templates + - report_tests_pretty_stdout + +# Specify which reports you'd like from the log factory +:report_tests_log_factory: + :reports: + - json + - junit + - cppunit + - html + +# override the default extensions for your system and toolchain +:extension: + :executable: .out + +# This is where Ceedling should look for your source and test files. +:paths: + :test: + - +:tests/c/** + - -:tests/c/support + :source: + - template_ext.c + :include: + - template_ext.h + :support: + - tests/c/support + :libraries: [] + +:files: + :test: [] + :source: [] + +:defines: + :test: + - TEST + - TESTING + :release: [] + :use_test_definition: FALSE + +:flags: + :release: + :compile: + - -Wall + - --O2 + :test: + :compile: + '*': + - -I$(php-config --include-dir) + - -I$(php-config --include-dir)/main + - -I$(php-config --include-dir)/TSRM + - -I$(php-config --include-dir)/Zend + - -I$(php-config --include-dir)/ext + - -I$(php-config --include-dir)/ext/date/lib + - -D_GNU_SOURCE + - -Wno-implicit-function-declaration + +:cmock: + :plugins: + - :ignore + - :callback + :verbosity: 2 + :when_no_prototypes: :warn + :strippables: [] + :attributes: + - __ramfunc + - __irq + - __fiq + - register + - extern + :c_calling_conventions: + - __stdcall + - __cdecl + - __fastcall + :treat_externs: :exclude + :treat_inlines: :exclude + :treat_as: + uint8: HEX8 + uint16: HEX16 + uint32: UINT32 + int8: INT8 + bool: UINT8 + :memcmp_if_unknown: true + :when_ptr: :compare_data + :enforce_strict_ordering: true + :fail_on_unexpected_calls: true + :callback_include_count: true + :callback_after_arg_check: false + :exclude_setjmp_h: false + +:unity: + :use_param_tests: true + :defines: + - UNITY_EXCLUDE_FLOAT + +:environment: [] + +:libraries: + :placement: :end + :flag: "-l${1}" + :path_flag: "-L ${1}" + :system: [] + :test: [] + :release: [] diff --git a/stubs/functions.stub.php b/stubs/functions.stub.php new file mode 100644 index 0000000..120c867 --- /dev/null +++ b/stubs/functions.stub.php @@ -0,0 +1,6 @@ + +#include + +/* Module startup */ +PHP_MINIT_FUNCTION(template_ext) +{ + return SUCCESS; +} + +/* Module shutdown */ +PHP_MSHUTDOWN_FUNCTION(template_ext) +{ + return SUCCESS; +} + +/* Request startup */ +PHP_RINIT_FUNCTION(template_ext) +{ + return SUCCESS; +} + +/* Request shutdown */ +PHP_RSHUTDOWN_FUNCTION(template_ext) +{ + return SUCCESS; +} + +/* Module info */ +PHP_MINFO_FUNCTION(template_ext) +{ + php_info_print_table_start(); + php_info_print_table_header(2, "template_ext", "enabled"); + php_info_print_table_end(); + + DISPLAY_INI_ENTRIES(); +} + +/* Helper function to check if a character is uppercase */ +static inline int is_uppercase(char c) +{ + return c >= 'A' && c <= 'Z'; +} + +/* Helper function to check if a character is alphanumeric */ +static inline int is_alphanumeric(char c) +{ + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); +} + +/* Converts a string to snake_case */ +PHP_FUNCTION(to_snake) +{ + char *input; + size_t input_len; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STRING(input, input_len) + ZEND_PARSE_PARAMETERS_END(); + + if (input_len == 0) { + RETURN_EMPTY_STRING(); + } + + // Allocate output buffer (worst case: every char becomes underscore + char) + char *output = emalloc(input_len * 2 + 1); + size_t output_len = 0; + int prev_was_separator = 0; + + for (size_t i = 0; i < input_len; i++) { + char c = input[i]; + + // Handle separators: convert -, space to underscore + if (c == '-' || c == ' ') { + if (output_len > 0 && !prev_was_separator) { + output[output_len++] = '_'; + } + prev_was_separator = 1; + continue; + } + + // Handle uppercase letters + if (is_uppercase(c)) { + // Add underscore before uppercase letter (except at start) + if (output_len > 0 && !prev_was_separator) { + // Check if previous character was lowercase (camelCase) + if (i > 0 && islower(input[i-1])) { + output[output_len++] = '_'; + } + } + output[output_len++] = tolower(c); + prev_was_separator = 0; + } else if (is_alphanumeric(c)) { + // Just copy lowercase letters and numbers + output[output_len++] = c; + prev_was_separator = 0; + } else { + // Skip other special characters + if (output_len > 0 && !prev_was_separator) { + prev_was_separator = 1; + } + } + } + + // Remove trailing underscore + while (output_len > 0 && output[output_len - 1] == '_') { + output_len--; + } + + output[output_len] = '\0'; + + RETVAL_STRINGL(output, output_len); + efree(output); +} + +/* Module entry */ +zend_module_entry template_ext_module_entry = { + STANDARD_MODULE_HEADER, + EXTENSION_NAME, + ext_functions, + PHP_MINIT(template_ext), + PHP_MSHUTDOWN(template_ext), + PHP_RINIT(template_ext), + PHP_RSHUTDOWN(template_ext), + PHP_MINFO(template_ext), + EXTENSION_VERSION, + STANDARD_MODULE_PROPERTIES +}; + +#ifdef COMPILE_DL_TEMPLATE_EXT +ZEND_GET_MODULE(template_ext) +#endif diff --git a/template_ext.h b/template_ext.h new file mode 100644 index 0000000..8abb62a --- /dev/null +++ b/template_ext.h @@ -0,0 +1,16 @@ +/* + * Header for template-ext PHP extension + */ + +#ifndef TEMPLATE_EXT_H +#define TEMPLATE_EXT_H + +#define EXTENSION_NAME "template_ext" +#define EXTENSION_VERSION "1.0.0" + +static const zend_function_entry ext_functions[] = { + ZEND_FE(to_snake, NULL) + ZEND_FE_END +}; + +#endif /* TEMPLATE_EXT_H */ diff --git a/tests/c/support/test_helpers.c b/tests/c/support/test_helpers.c new file mode 100644 index 0000000..ed80506 --- /dev/null +++ b/tests/c/support/test_helpers.c @@ -0,0 +1,70 @@ +#include "unity.h" +#include "test_helpers.h" +#include +#include +#include + +// Helper function to check if a character is uppercase +static inline int is_uppercase(char c) +{ + return c >= 'A' && c <= 'Z'; +} + +// String to snake_case conversion function +char* to_snake_calc(const char* input) +{ + if (input == NULL || strlen(input) == 0) { + char* result = emalloc(1); + result[0] = '\0'; + return result; + } + + // Allocate output buffer (worst case: every char becomes underscore + char) + char* output = emalloc(strlen(input) * 2 + 1); + size_t output_len = 0; + int prev_was_separator = 0; + + for (size_t i = 0; input[i]; i++) { + char c = input[i]; + + // Handle separators: convert -, space to underscore + if (c == '-' || c == ' ') { + if (output_len > 0 && !prev_was_separator) { + output[output_len++] = '_'; + } + prev_was_separator = 1; + continue; + } + + // Handle uppercase letters + if (is_uppercase(c)) { + // Add underscore before uppercase letter (except at start) + if (output_len > 0 && !prev_was_separator) { + // Check if previous character was lowercase (camelCase) + if (i > 0 && islower(input[i-1])) { + output[output_len++] = '_'; + } + } + output[output_len++] = tolower(c); + prev_was_separator = 0; + } else if (isalnum(c)) { + // Just copy lowercase letters and numbers + output[output_len++] = c; + prev_was_separator = 0; + } else { + // Skip other special characters + if (output_len > 0 && !prev_was_separator) { + prev_was_separator = 1; + } + } + } + + // Remove trailing underscore + while (output_len > 0 && output[output_len - 1] == '_') { + output_len--; + } + + output[output_len] = '\0'; + + return output; +} diff --git a/tests/c/support/test_helpers.h b/tests/c/support/test_helpers.h new file mode 100644 index 0000000..c16061e --- /dev/null +++ b/tests/c/support/test_helpers.h @@ -0,0 +1,7 @@ +#ifndef TEST_HELPERS_H +#define TEST_HELPERS_H + +// String to snake_case conversion function for testing +char* to_snake_calc(const char* input); + +#endif /* TEST_HELPERS_H */ diff --git a/tests/c/test_to_snake.c b/tests/c/test_to_snake.c new file mode 100644 index 0000000..572f10a --- /dev/null +++ b/tests/c/test_to_snake.c @@ -0,0 +1,155 @@ +#include "unity.h" +#include "test_helpers.h" + +// ============= Basic Cases ============= + +TEST_CASE("FirstName", "first_name") +TEST_CASE("firstName", "first_name") +TEST_CASE("first_name", "first_name") +TEST_CASE("first-name", "first_name") +TEST_CASE("first name", "first_name") +TEST_CASE("Name", "name") +TEST_CASE("HTTPResponse", "http_response") +TEST_CASE("user2Name", "user2_name") +TEST_CASE("getHTTPResponseCode", "get_http_response_code") +TEST_CASE("IOError", "io_error") +void test_to_snake_case_basic(const char* input, const char* expected) { + char* result = to_snake_calc(input); + TEST_ASSERT_EQUAL_STRING(expected, result); + efree(result); +} + +// ============= Edge Cases ============= + +void test_to_snake_empty_string(void) { + char* result = to_snake_calc(""); + TEST_ASSERT_EQUAL_STRING("", result); + efree(result); +} + +void test_to_snake_single_character(void) { + char* result = to_snake_calc("a"); + TEST_ASSERT_EQUAL_STRING("a", result); + efree(result); + + result = to_snake_calc("A"); + TEST_ASSERT_EQUAL_STRING("a", result); + efree(result); +} + +void test_to_snake_all_uppercase(void) { + char* result = to_snake_calc("HELLO"); + TEST_ASSERT_EQUAL_STRING("hello", result); + efree(result); +} + +void test_to_snake_all_lowercase(void) { + char* result = to_snake_calc("hello"); + TEST_ASSERT_EQUAL_STRING("hello", result); + efree(result); +} + +void test_to_snake_numbers(void) { + char* result = to_snake_calc("user2Name"); + TEST_ASSERT_EQUAL_STRING("user2_name", result); + efree(result); + + result = to_snake_calc("123ABC"); + TEST_ASSERT_EQUAL_STRING("123abc", result); + efree(result); +} + +void test_to_snake_multiple_separators(void) { + char* result = to_snake_calc("first name"); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); + + result = to_snake_calc("first--name"); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); + + result = to_snake_calc("first - name"); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); +} + +void test_to_snake_leading_trailing_separators(void) { + char* result = to_snake_calc(" first_name "); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); + + result = to_snake_calc("-first_name-"); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); +} + +// ============= Special Characters ============= + +void test_to_snake_special_chars(void) { + // Special characters should be removed + char* result = to_snake_calc("first@name"); + TEST_ASSERT_EQUAL_STRING("first_name", result); + efree(result); + + result = to_snake_calc("first.name"); + TEST_ASSERT_EQUAL_STRING("firstname", result); + efree(result); +} + +// ============= Consecutive Uppercase ============= + +void test_to_snake_consecutive_uppercase(void) { + char* result = to_snake_calc("HTTPResponse"); + TEST_ASSERT_EQUAL_STRING("http_response", result); + efree(result); + + result = to_snake_calc("XMLParser"); + TEST_ASSERT_EQUAL_STRING("xml_parser", result); + efree(result); + + result = to_snake_calc("HTMLElement"); + TEST_ASSERT_EQUAL_STRING("html_element", result); + efree(result); +} + +// ============= Test Matrix for Multiple Values ============= + +// Test that single words remain unchanged +TEST_MATRIX(["hello", "world", "test", "name"]) +void test_to_snake_single_word(const char* input) { + char* result = to_snake_calc(input); + TEST_ASSERT_EQUAL_STRING(input, result); + efree(result); +} + +// Test camelCase to snake_case +TEST_MATRIX(["camelCase", "PascalCase", "longVariableName"]) +void test_to_snake_camel_case(const char* input) { + char* result = to_snake_calc(input); + // camelCase -> camel_case, PascalCase -> pascal_case, longVariableName -> long_variable_name + char* expected = emalloc(strlen(input) * 2 + 1); + size_t j = 0; + for (size_t i = 0; input[i]; i++) { + if (isupper(input[i]) && i > 0 && islower(input[i-1])) { + expected[j++] = '_'; + } + expected[j++] = tolower(input[i]); + } + expected[j] = '\0'; + TEST_ASSERT_EQUAL_STRING(expected, result); + efree(expected); + efree(result); +} + +// Test that all uppercase single words become lowercase +TEST_MATRIX(["HELLO", "WORLD", "TEST", "NAME"]) +void test_to_snake_all_uppercase_single_word(const char* input) { + char* result = to_snake_calc(input); + char expected[100]; + for (size_t i = 0; input[i]; i++) { + expected[i] = tolower(input[i]); + } + expected[strlen(input)] = '\0'; + TEST_ASSERT_EQUAL_STRING(expected, result); + efree(result); +} diff --git a/tests/php/Integration/ToSnakeTest.php b/tests/php/Integration/ToSnakeTest.php new file mode 100644 index 0000000..14f3a1e --- /dev/null +++ b/tests/php/Integration/ToSnakeTest.php @@ -0,0 +1,72 @@ +toBe('camel_case'); + expect(to_snake('PascalCase'))->toBe('pascal_case'); + expect(to_snake('longVariableName'))->toBe('long_variable_name'); +}); + +it('converts PascalCase to snake_case', function () { + expect(to_snake('FirstName'))->toBe('first_name'); + expect(to_snake('HTTPResponse'))->toBe('http_response'); + expect(to_snake('XMLParser'))->toBe('xml_parser'); +}); + +it('converts kebab-case to snake_case', function () { + expect(to_snake('first-name'))->toBe('first_name'); + expect(to_snake('long-variable-name'))->toBe('long_variable_name'); +}); + +it('converts space separated to snake_case', function () { + expect(to_snake('first name'))->toBe('first_name'); + expect(to_snake('long variable name'))->toBe('long_variable_name'); +}); + +it('handles already snake_case strings', function () { + expect(to_snake('first_name'))->toBe('first_name'); + expect(to_snake('long_variable_name'))->toBe('long_variable_name'); +}); + +it('handles empty string', function () { + expect(to_snake(''))->toBe(''); +}); + +it('handles single character', function () { + expect(to_snake('a'))->toBe('a'); + expect(to_snake('A'))->toBe('a'); +}); + +it('handles all uppercase', function () { + expect(to_snake('HELLO'))->toBe('hello'); + expect(to_snake('WORLD'))->toBe('world'); +}); + +it('handles all lowercase', function () { + expect(to_snake('hello'))->toBe('hello'); + expect(to_snake('world'))->toBe('world'); +}); + +it('handles numbers', function () { + expect(to_snake('user2Name'))->toBe('user2_name'); + expect(to_snake('123ABC'))->toBe('123abc'); +}); + +it('handles multiple separators', function () { + expect(to_snake('first name'))->toBe('first_name'); + expect(to_snake('first--name'))->toBe('first_name'); +}); + +it('handles leading and trailing separators', function () { + expect(to_snake(' first_name '))->toBe('first_name'); + expect(to_snake('-first_name-'))->toBe('first_name'); +}); + +it('handles consecutive uppercase letters', function () { + expect(to_snake('HTTPResponse'))->toBe('http_response'); + expect(to_snake('XMLParser'))->toBe('xml_parser'); + expect(to_snake('IOError'))->toBe('io_error'); +}); diff --git a/tests/php/Pest.php b/tests/php/Pest.php new file mode 100644 index 0000000..d4db083 --- /dev/null +++ b/tests/php/Pest.php @@ -0,0 +1,8 @@ +group('integration')->in('Integration');