diff --git a/.changepacks/config.json b/.changepacks/config.json index be707995..9229667c 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -1,6 +1,6 @@ { - "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!libs/braillify/Cargo.toml"], + "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!packages/c/Cargo.toml", "!libs/braillify/Cargo.toml"], "baseBranch": "main", "latestPackage": null, "publish": {} -} \ No newline at end of file +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dbcca7b4..871220c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,6 +85,12 @@ jobs: - name: Test (Python) timeout-minutes: 10 run: bun run test:python + - name: Set up MSVC for C/C++ tests + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + - name: Test (C and C++) + timeout-minutes: 5 + run: bun run test:c - name: Format Rollback shell: bash run: | @@ -819,3 +825,140 @@ jobs: name: nuget-packages path: packages/dotnet/**/nupkg/*.nupkg retention-days: 1 + + # c + c-build: + name: C Build - ${{ matrix.rid }} + runs-on: ${{ matrix.runner }} + if: ${{ contains(needs.changepacks.outputs.changepacks, 'packages/c/Cargo.toml') }} + needs: + - test + - changepacks + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + rid: linux-x64 + archive: braillify-c-linux-x64.tar.gz + - runner: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + rid: linux-arm64 + archive: braillify-c-linux-arm64.tar.gz + - runner: macos-14 + target: x86_64-apple-darwin + rid: macos-x64 + archive: braillify-c-macos-x64.tar.gz + - runner: macos-14 + target: aarch64-apple-darwin + rid: macos-arm64 + archive: braillify-c-macos-arm64.tar.gz + - runner: windows-2022 + target: x86_64-pc-windows-msvc + rid: windows-x64 + archive: braillify-c-windows-x64.tar.gz + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + + - name: Set up MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.rid == 'linux-arm64' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build C libraries + run: cargo build --release --target ${{ matrix.target }} -p braillify-c + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Bundle libraries and header + shell: bash + env: + TARGET: ${{ matrix.target }} + RID: ${{ matrix.rid }} + ARCHIVE: ${{ matrix.archive }} + run: | + mkdir -p "dist/$RID/include" "dist/$RID/lib" + cp packages/c/include/braillify.h "dist/$RID/include/" + cp packages/c/README.md "dist/$RID/" + case "$RUNNER_OS" in + Linux) + cp "target/$TARGET/release/libbraillify_c.a" "dist/$RID/lib/" + cp "target/$TARGET/release/libbraillify_c.so" "dist/$RID/lib/" + ;; + macOS) + cp "target/$TARGET/release/libbraillify_c.a" "dist/$RID/lib/" + cp "target/$TARGET/release/libbraillify_c.dylib" "dist/$RID/lib/" + ;; + Windows) + cp "target/$TARGET/release/braillify_c.dll" "dist/$RID/lib/" + cp "target/$TARGET/release/braillify_c.dll.lib" "dist/$RID/lib/" + cp "target/$TARGET/release/braillify_c.lib" "dist/$RID/lib/" + ;; + esac + tar -C dist -czf "$ARCHIVE" "$RID" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: c-bindings-${{ matrix.rid }} + path: ${{ matrix.archive }} + if-no-files-found: error + retention-days: 1 + + c-publish: + name: C Publish + runs-on: ubuntu-latest + if: ${{ contains(needs.changepacks.outputs.changepacks, 'packages/c/Cargo.toml') }} + needs: + - changepacks + - c-build + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Download C artifacts + uses: actions/download-artifact@v8 + with: + pattern: c-bindings-* + path: c-artifacts + merge-multiple: true + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v4 + with: + subject-path: "c-artifacts/*.tar.gz" + + - name: Delete existing release assets (idempotent re-run) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_URL: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/c/Cargo.toml'] }} + run: | + RELEASE_ID=$(echo "$UPLOAD_URL" | sed -E 's#.*/releases/([0-9]+)/assets.*#\1#') + REPO="${GITHUB_REPOSITORY}" + for ARCHIVE in c-artifacts/*.tar.gz; do + NAME=$(basename "$ARCHIVE") + ASSET_ID=$(gh api "repos/$REPO/releases/$RELEASE_ID/assets" \ + --paginate --jq ".[] | select(.name == \"$NAME\") | .id" || true) + if [ -n "$ASSET_ID" ]; then + gh api -X DELETE "repos/$REPO/releases/assets/$ASSET_ID" || true + fi + done + + - name: Upload release assets + uses: owjs3901/upload-github-release-asset@main + with: + upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/c/Cargo.toml'] }} + asset_path: "c-artifacts/*.tar.gz" diff --git a/Cargo.lock b/Cargo.lock index b4106441..7cd6aca8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,13 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "braillify-c" +version = "0.1.0" +dependencies = [ + "braillify", +] + [[package]] name = "bstr" version = "1.12.3" diff --git a/package.json b/package.json index 752803ba..7b26dfdb 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "scripts": { "lint": "oxlint . && cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings", "lint:fix": "oxlint . --fix && cargo fmt --all && cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings && cargo clippy --workspace --all-targets -- -D warnings", - "test": "bun run test:rust && bun run test:node && bun run test:python", + "test": "bun run test:rust && bun run test:c && bun run test:node && bun run test:python", "test:rust": "bun scripts/test-rust.ts", "test:node": "bun test", "test:python": "cd py-test && uv run --locked pytest", + "test:c": "bun scripts/test-c.ts", "test:testcase": "cargo test -p braillify test_by_testcase -- --nocapture", "preinstall": "uv sync && (cargo install wasm-pack || node -e \"process.exit(0)\") && (pip install maturin || \"process.exit(0)\")", - "build": "cargo build --release -p braillify && bun -F braillify build && cd packages/python && maturin build --release --out dist", + "build": "cargo build --release -p braillify && cargo build --release -p braillify-c && bun -F braillify build && cd packages/python && maturin build --release --out dist", "build:landing": "bun run build && (cargo test test_by_testcase || node -e \"process.exit(0)\") && bun -F landing build", "changepacks": "bunx @changepacks/cli", "dev": "bun -F landing dev" diff --git a/packages/c/.gitignore b/packages/c/.gitignore new file mode 100644 index 00000000..1153da5e --- /dev/null +++ b/packages/c/.gitignore @@ -0,0 +1,2 @@ +/tests/smoke + diff --git a/packages/c/Cargo.toml b/packages/c/Cargo.toml new file mode 100644 index 00000000..62553546 --- /dev/null +++ b/packages/c/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "braillify-c" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +description = "C ABI bindings for Braillify" +license = "MIT OR Apache-2.0" +repository = "https://github.com/dev-five-git/braillify" + +[lib] +name = "braillify_c" +crate-type = ["cdylib", "staticlib", "rlib"] +doctest = false + +[dependencies] +braillify = { path = "../../libs/braillify", default-features = false } diff --git a/packages/c/Makefile b/packages/c/Makefile new file mode 100644 index 00000000..750fcfea --- /dev/null +++ b/packages/c/Makefile @@ -0,0 +1,23 @@ +TARGET_DIR ?= ../../target +PROFILE ?= debug +LIB_DIR := $(TARGET_DIR)/$(PROFILE) +UNAME_S := $(shell uname -s) + +ifeq ($(UNAME_S),Darwin) + RPATH_FLAG := -Wl,-rpath,@loader_path/../../../target/$(PROFILE) +else + RPATH_FLAG := -Wl,-rpath,'$$ORIGIN/../../../target/$(PROFILE)' +endif + +.PHONY: build test clean + +build: + cargo build -p braillify-c + +test: build + $(CC) -std=c11 -Wall -Wextra -Werror -Iinclude tests/smoke.c \ + -L$(LIB_DIR) -lbraillify_c $(RPATH_FLAG) -o tests/smoke + ./tests/smoke + +clean: + $(RM) tests/smoke diff --git a/packages/c/README.md b/packages/c/README.md new file mode 100644 index 00000000..de6cad16 --- /dev/null +++ b/packages/c/README.md @@ -0,0 +1,66 @@ +# braillify-c + +한국어 텍스트를 2024 개정 한국 점자 규정에 따라 변환하는 Braillify의 C 바인딩입니다. 동일한 헤더를 C와 C++에서 사용할 수 있습니다. + +## 빌드 + +저장소 루트에서 동적·정적 라이브러리를 빌드합니다. + +```bash +cargo build --release -p braillify-c +``` + +빌드 결과는 플랫폼에 따라 `target/release/libbraillify_c.so`, `libbraillify_c.dylib`, `braillify_c.dll` 및 정적 라이브러리로 생성됩니다. 공개 헤더는 `include/braillify.h`입니다. + +## 사용법 + +```c +#include +#include + +int main(void) { + char *result = braillify_encode_unicode("안녕하세요"); + if (result == NULL) { + char *error = braillify_last_error(); + fprintf(stderr, "%s\n", error != NULL ? error : "unknown error"); + braillify_string_free(error); + return 1; + } + + puts(result); + braillify_string_free(result); + return 0; +} +``` + +Linux에서는 다음과 같이 링크할 수 있습니다. + +```bash +cc example.c -Ipath/to/packages/c/include -Lpath/to/target/release \ + -lbraillify_c -o example +``` + +동적 라이브러리를 실행 시 검색할 수 있도록 `LD_LIBRARY_PATH`(Linux), `DYLD_LIBRARY_PATH`(macOS) 또는 `PATH`(Windows)를 설정하거나 애플리케이션에 rpath를 지정해야 합니다. + +## API와 메모리 소유권 + +- 입력은 NUL로 끝나는 UTF-8 문자열이어야 합니다. +- `braillify_encode_unicode`와 `braillify_encode_braille_font`의 반환값은 `braillify_string_free`로 해제합니다. +- `braillify_encode`의 반환값은 함께 받은 길이를 그대로 사용해 `braillify_bytes_free`로 해제합니다. +- 실패 시 인코딩 함수는 `NULL`을 반환합니다. `braillify_last_error`가 반환한 메시지도 `braillify_string_free`로 해제합니다. +- 마지막 오류는 스레드별로 저장되며, 다음 인코딩 호출이 시작되면 초기화됩니다. +- 모든 해제 함수는 `NULL`을 허용합니다. + +## C++ + +헤더가 선언을 `extern "C"`로 감싸므로 C++에서도 그대로 포함할 수 있습니다. 반환된 메모리는 `delete`나 `free`가 아니라 반드시 위의 Braillify 해제 함수로 반환해야 합니다. + +## 테스트 + +```bash +cargo test -p braillify-c +make -C packages/c test +bun run test:c +``` + +`make` 명령은 실제 C11 컴파일러로 공개 헤더와 동적 라이브러리를 링크해 스모크 테스트를 실행합니다. `bun run test:c`는 같은 공개 헤더를 C11과 C++17로 각각 컴파일하고 라이브러리에 링크한 뒤 두 실행 파일을 모두 검증합니다. diff --git a/packages/c/include/braillify.h b/packages/c/include/braillify.h new file mode 100644 index 00000000..e278c916 --- /dev/null +++ b/packages/c/include/braillify.h @@ -0,0 +1,41 @@ +#ifndef BRAILLIFY_H +#define BRAILLIFY_H + +#include +#include + +#if defined(_WIN32) && defined(BRAILLIFY_SHARED) +# if defined(BRAILLIFY_BUILD) +# define BRAILLIFY_API __declspec(dllexport) +# else +# define BRAILLIFY_API __declspec(dllimport) +# endif +#else +# define BRAILLIFY_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * All input strings must be NUL-terminated UTF-8. Returned pointers are owned + * by the caller and must be released with the matching function below. + * On failure, encoding functions return NULL and braillify_last_error() + * returns a newly allocated description for the calling thread. + */ + +BRAILLIFY_API uint8_t *braillify_encode(const char *text, size_t *out_len); +BRAILLIFY_API char *braillify_encode_unicode(const char *text); +BRAILLIFY_API char *braillify_encode_braille_font(const char *text); +BRAILLIFY_API char *braillify_last_error(void); + +BRAILLIFY_API void braillify_bytes_free(uint8_t *bytes, size_t len); +BRAILLIFY_API void braillify_string_free(char *value); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* BRAILLIFY_H */ + diff --git a/packages/c/src/lib.rs b/packages/c/src/lib.rs new file mode 100644 index 00000000..4ddb0c91 --- /dev/null +++ b/packages/c/src/lib.rs @@ -0,0 +1,335 @@ +use std::cell::RefCell; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr; + +const PANIC_ERROR: &str = "internal panic in braillify"; + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_last_error(error: impl Into) { + LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(error.into())); +} + +fn clear_last_error() { + LAST_ERROR.with(|slot| *slot.borrow_mut() = None); +} + +unsafe fn input_from_ptr<'a>(text: *const c_char) -> Result<&'a str, String> { + if text.is_null() { + return Err("text must not be NULL".to_owned()); + } + + // SAFETY: The exported functions document that `text` must point to a + // readable NUL-terminated byte string for the duration of the call. + let text = unsafe { CStr::from_ptr(text) }; + text.to_str() + .map_err(|error| format!("text must be valid UTF-8: {error}")) +} + +fn string_into_raw(value: String) -> Result<*mut c_char, String> { + CString::new(value) + .map(CString::into_raw) + .map_err(|_| "encoded output unexpectedly contained a NUL byte".to_owned()) +} + +unsafe fn encode_string( + text: *const c_char, + encode: impl FnOnce(&str) -> Result, +) -> *mut c_char { + clear_last_error(); + let result = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The caller of this helper provides the same pointer guarantee + // as the exported string-encoding functions. + unsafe { input_from_ptr(text) } + .and_then(encode) + .and_then(string_into_raw) + })); + + match result { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + set_last_error(error); + ptr::null_mut() + } + Err(_) => { + set_last_error(PANIC_ERROR); + ptr::null_mut() + } + } +} + +unsafe fn encode_bytes( + text: *const c_char, + out_len: *mut usize, + encode: impl FnOnce(&str) -> Result, String>, +) -> *mut u8 { + clear_last_error(); + if out_len.is_null() { + set_last_error("out_len must not be NULL"); + return ptr::null_mut(); + } + // SAFETY: `out_len` was checked above and is required by this function's + // contract to point to writable memory. + unsafe { *out_len = 0 }; + + let result = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The caller of this helper provides the same pointer guarantee + // as the exported byte-encoding function. + unsafe { input_from_ptr(text) }.and_then(encode) + })); + match result { + Ok(Ok(bytes)) => { + let len = bytes.len(); + let allocation = bytes.into_boxed_slice(); + // SAFETY: `out_len` satisfies the documented caller contract. + unsafe { *out_len = len }; + Box::into_raw(allocation).cast::() + } + Ok(Err(error)) => { + set_last_error(error); + ptr::null_mut() + } + Err(_) => { + set_last_error(PANIC_ERROR); + ptr::null_mut() + } + } +} + +/// Returns a newly allocated copy of the last error for the current thread. +/// +/// The caller owns the returned string and must release it with +/// [`braillify_string_free`]. Returns `NULL` when no error has occurred. +#[unsafe(no_mangle)] +pub extern "C" fn braillify_last_error() -> *mut c_char { + LAST_ERROR.with(|slot| { + slot.borrow() + .as_ref() + .and_then(|error| CString::new(error.as_str()).ok()) + .map_or(ptr::null_mut(), CString::into_raw) + }) +} + +/// Encodes UTF-8 text into Braille cell bytes. +/// +/// On success, returns an allocation owned by the caller and writes its size +/// to `out_len`. Release it with [`braillify_bytes_free`] using the same size. +/// On failure, returns `NULL`, writes zero to `out_len`, and records an error. +/// +/// # Safety +/// +/// `text` must point to a readable NUL-terminated byte string for the duration +/// of the call. `out_len` must point to writable memory. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode(text: *const c_char, out_len: *mut usize) -> *mut u8 { + // SAFETY: Both pointers satisfy this exported function's caller contract. + unsafe { encode_bytes(text, out_len, braillify::encode) } +} + +/// Encodes UTF-8 text as a newly allocated UTF-8 Unicode Braille string. +/// +/// Release the returned string with [`braillify_string_free`]. +/// +/// # Safety +/// +/// `text` must point to a readable NUL-terminated byte string for the duration +/// of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_unicode(text: *const c_char) -> *mut c_char { + // SAFETY: `text` satisfies this exported function's caller contract. + unsafe { encode_string(text, braillify::encode_to_unicode) } +} + +/// Encodes UTF-8 text as a newly allocated NUL-terminated Braille-font string. +/// +/// Release the returned string with [`braillify_string_free`]. +/// +/// # Safety +/// +/// `text` must point to a readable NUL-terminated byte string for the duration +/// of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_braille_font(text: *const c_char) -> *mut c_char { + // SAFETY: `text` satisfies this exported function's caller contract. + unsafe { encode_string(text, braillify::encode_to_braille_font) } +} + +/// Releases a string returned by this library. Passing `NULL` is allowed. +/// +/// # Safety +/// +/// `value` must be `NULL` or a pointer returned by a string-returning function +/// in this library, and it must not have been released previously. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_string_free(value: *mut c_char) { + if !value.is_null() { + // SAFETY: The caller guarantees ownership of a compatible allocation. + unsafe { drop(CString::from_raw(value)) }; + } +} + +/// Releases bytes returned by [`braillify_encode`]. Passing `NULL` is allowed. +/// +/// # Safety +/// +/// `bytes` and `len` must be the exact pair returned by [`braillify_encode`], +/// and the allocation must not have been released previously. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_bytes_free(bytes: *mut u8, len: usize) { + if !bytes.is_null() { + let slice = ptr::slice_from_raw_parts_mut(bytes, len); + // SAFETY: `slice` reconstructs the boxed slice returned by encode. + unsafe { drop(Box::from_raw(slice)) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(value: &str) -> CString { + CString::new(value).expect("test input must not contain NUL") + } + + fn take_error() -> Option { + let error = braillify_last_error(); + if error.is_null() { + return None; + } + // SAFETY: The pointer came from `braillify_last_error` and remains + // valid until it is released below. + let message = unsafe { CStr::from_ptr(error) } + .to_str() + .expect("errors are valid UTF-8") + .to_owned(); + // SAFETY: This function owns the returned error allocation. + unsafe { braillify_string_free(error) }; + Some(message) + } + + #[test] + fn encode_returns_owned_bytes() { + let input = input("안녕"); + let mut len = 0; + // SAFETY: Both arguments are valid for this call. + let bytes = unsafe { braillify_encode(input.as_ptr(), &mut len) }; + assert!(!bytes.is_null()); + assert!(len > 0); + assert!(take_error().is_none()); + // SAFETY: This is the exact pointer/length pair returned above. + unsafe { braillify_bytes_free(bytes, len) }; + } + + #[test] + fn unicode_returns_owned_utf8_string() { + let input = input("안녕"); + // SAFETY: `input` is a valid NUL-terminated string. + let encoded = unsafe { braillify_encode_unicode(input.as_ptr()) }; + assert!(!encoded.is_null()); + // SAFETY: `encoded` is a valid string until released below. + let result = unsafe { CStr::from_ptr(encoded) }.to_str().unwrap(); + assert!(!result.is_empty()); + assert!( + result + .chars() + .all(|ch| ('\u{2800}'..='\u{28ff}').contains(&ch)) + ); + // SAFETY: This test owns the allocation. + unsafe { braillify_string_free(encoded) }; + } + + #[test] + fn braille_font_returns_owned_string() { + let input = input("안녕"); + // SAFETY: `input` is a valid NUL-terminated string. + let encoded = unsafe { braillify_encode_braille_font(input.as_ptr()) }; + assert!(!encoded.is_null()); + // SAFETY: This test owns the allocation. + unsafe { braillify_string_free(encoded) }; + } + + #[test] + fn null_text_records_an_error_and_zeroes_length() { + let mut len = usize::MAX; + // SAFETY: A null text pointer is an explicitly handled invalid input. + let bytes = unsafe { braillify_encode(ptr::null(), &mut len) }; + assert!(bytes.is_null()); + assert_eq!(len, 0); + assert_eq!(take_error().as_deref(), Some("text must not be NULL")); + } + + #[test] + fn null_length_records_an_error() { + let input = input("안녕"); + // SAFETY: A null output pointer is an explicitly handled invalid input. + let bytes = unsafe { braillify_encode(input.as_ptr(), ptr::null_mut()) }; + assert!(bytes.is_null()); + assert_eq!(take_error().as_deref(), Some("out_len must not be NULL")); + } + + #[test] + fn invalid_utf8_records_an_error() { + let input = [0xff_u8, 0]; + // SAFETY: The buffer is readable and NUL-terminated; invalid UTF-8 is + // handled by the binding. + let encoded = unsafe { braillify_encode_unicode(input.as_ptr().cast()) }; + assert!(encoded.is_null()); + assert!( + take_error() + .unwrap() + .starts_with("text must be valid UTF-8") + ); + } + + #[test] + fn engine_error_is_exposed_and_success_clears_it() { + let unsupported = input("😀"); + // SAFETY: `unsupported` is a valid NUL-terminated string. + let encoded = unsafe { braillify_encode_unicode(unsupported.as_ptr()) }; + assert!(encoded.is_null()); + assert!(take_error().is_some()); + + let supported = input("안녕"); + // SAFETY: `supported` is a valid NUL-terminated string. + let encoded = unsafe { braillify_encode_unicode(supported.as_ptr()) }; + assert!(!encoded.is_null()); + assert!(take_error().is_none()); + // SAFETY: This test owns the allocation. + unsafe { braillify_string_free(encoded) }; + } + + #[test] + fn free_functions_accept_null() { + // SAFETY: Both free functions explicitly accept NULL. + unsafe { + braillify_string_free(ptr::null_mut()); + braillify_bytes_free(ptr::null_mut(), 0); + } + } + + #[test] + fn byte_encoder_catches_panics_and_zeroes_length() { + let input = input("안녕"); + let mut len = usize::MAX; + // SAFETY: Both arguments are valid for this call. The injected encoder + // deliberately panics to exercise the ABI boundary. + let encoded = unsafe { encode_bytes(input.as_ptr(), &mut len, |_| panic!("test panic")) }; + assert!(encoded.is_null()); + assert_eq!(len, 0); + assert_eq!(take_error().as_deref(), Some(PANIC_ERROR)); + } + + #[test] + fn string_encoder_catches_panics() { + let input = input("안녕"); + // SAFETY: `input` is valid. The injected encoder deliberately panics to + // exercise the ABI boundary. + let encoded = unsafe { encode_string(input.as_ptr(), |_| panic!("test panic")) }; + assert!(encoded.is_null()); + assert_eq!(take_error().as_deref(), Some(PANIC_ERROR)); + } +} diff --git a/packages/c/tests/smoke.c b/packages/c/tests/smoke.c new file mode 100644 index 00000000..46fc8a16 --- /dev/null +++ b/packages/c/tests/smoke.c @@ -0,0 +1,18 @@ +#include "braillify.h" + +#include + +int main(void) { + char *braille = braillify_encode_unicode("안녕하세요"); + if (braille == NULL) { + char *error = braillify_last_error(); + fprintf(stderr, "braillify: %s\n", error != NULL ? error : "unknown error"); + braillify_string_free(error); + return 1; + } + + puts(braille); + braillify_string_free(braille); + return 0; +} + diff --git a/scripts/test-c.ts b/scripts/test-c.ts new file mode 100644 index 00000000..6b9b3dac --- /dev/null +++ b/scripts/test-c.ts @@ -0,0 +1,73 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = resolve(root, "target", "debug"); +const source = resolve(root, "packages", "c", "tests", "smoke.c"); +const include = resolve(root, "packages", "c", "include"); +mkdirSync(target, { recursive: true }); + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +run("cargo", ["build", "-p", "braillify-c"]); + +if (process.platform === "win32") { + const importLibrary = resolve(target, "braillify_c.dll.lib"); + for (const [language, flag] of [ + ["c", "/TC"], + ["cpp", "/TP"], + ] as const) { + const output = resolve(target, `braillify-${language}-smoke.exe`); + run(process.env.CC ?? "cl.exe", [ + "/nologo", + flag, + "/utf-8", + "/W4", + "/WX", + `/I${include}`, + source, + "/link", + `/LIBPATH:${target}`, + importLibrary, + `/OUT:${output}`, + ]); + run(output, []); + } +} else { + const rpath = `-Wl,-rpath,${target}`; + for (const [language, compiler, standard] of [ + ["c", process.env.CC ?? "cc", "c11"], + ["cpp", process.env.CXX ?? "c++", "c++17"], + ] as const) { + const output = resolve(target, `braillify-${language}-smoke`); + const object = resolve(target, `braillify-${language}-smoke.o`); + run(compiler, [ + `-std=${standard}`, + "-x", + language === "c" ? "c" : "c++", + "-Wall", + "-Wextra", + "-Werror", + `-I${include}`, + "-c", + source, + "-o", + object, + ]); + run(compiler, [ + object, + `-L${target}`, + "-lbraillify_c", + rpath, + "-o", + output, + ]); + run(output, []); + } +}