diff --git a/.changepacks/config.json b/.changepacks/config.json index be707995..0f8812c8 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -1,5 +1,5 @@ { - "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", "!libs/braillify/Cargo.toml", "!packages/go/Cargo.toml"], "baseBranch": "main", "latestPackage": null, "publish": {} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d24c8348..dba83593 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,16 +18,20 @@ jobs: fail-fast: false matrix: python-version: - - '3.11' - - '3.12' - - '3.13' - - '3.14' + - "3.11" + - "3.12" + - "3.13" + - "3.14" platform: - ubuntu-latest - windows-latest - macos-latest steps: - - uses: actions/checkout@v5 + # pull_request_target는 보안상 기본적으로 base branch(main)를 체크아웃한다. + # PR 코드의 커버리지를 측정하려면 PR head를 명시적으로 지정해야 한다. + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -36,7 +40,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v8.1.0 - name: Install maturin run: uv pip install maturin --system - uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -55,10 +59,27 @@ jobs: run: bun run build - name: Lint run: bun run lint + - name: reformat + shell: bash + run: | + echo 'max_width = 100000' > .rustfmt.toml + echo 'tab_spaces = 4' >> .rustfmt.toml + echo 'newline_style = "Unix"' >> .rustfmt.toml + echo 'fn_call_width = 100000' >> .rustfmt.toml + echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml + echo 'chain_width = 100000' >> .rustfmt.toml + echo 'merge_derives = true' >> .rustfmt.toml + echo 'use_small_heuristics = "Default"' >> .rustfmt.toml + cargo fmt - name: Test run: bun run test + - name: Format Rollback + shell: bash + run: | + rm -rf .rustfmt.toml + cargo fmt - name: Upload to codecov.io - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true @@ -98,7 +119,10 @@ jobs: rid: osx-x64 lib_name: libbraillify_native.dylib steps: - - uses: actions/checkout@v5 + # pull_request_target에서도 PR 코드를 체크아웃해 테스트한다. + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -111,7 +135,7 @@ jobs: - name: Setup .NET if: matrix.dotnet_arch != 'x86' && matrix.rid != 'osx-x64' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.x @@ -170,16 +194,16 @@ jobs: - test - dotnet-test steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v8.1.0 - name: Install maturin run: uv pip install maturin --system - uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -196,10 +220,10 @@ jobs: - name: Build Landing run: bun run build:landing - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./apps/landing/out - - uses: actions/deploy-pages@v4 + - uses: actions/deploy-pages@v5 # publish changepacks: @@ -215,8 +239,9 @@ jobs: needs: - test - dotnet-test + - go-test steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: changepacks/action@main id: changepacks with: @@ -252,12 +277,12 @@ jobs: runs-on: ${{ matrix.settings.host }} env: # DEBUG: napi:* - MACOSX_DEPLOYMENT_TARGET: '10.13' - CARGO_INCREMENTAL: '1' + MACOSX_DEPLOYMENT_TARGET: "10.13" + CARGO_INCREMENTAL: "1" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 22 - uses: oven-sh/setup-bun@v2 @@ -266,9 +291,9 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v8.1.0 - name: Install maturin run: uv pip install maturin --system - name: Install @@ -298,13 +323,13 @@ jobs: shell: bash working-directory: packages/node - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pkg-${{ matrix.settings.target }} path: | packages/node/pkg/* if-no-files-found: error - + # python python-build: runs-on: ${{ matrix.runner }} @@ -375,25 +400,24 @@ jobs: target: aarch64 os: macos steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: 3.x architecture: ${{ matrix.python-target || '' }} - name: Build wheels - uses: PyO3/maturin-action@main + uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} args: --release --out dist --find-interpreter working-directory: packages/python manylinux: ${{ matrix.manylinux || '' }} - name: Upload wheels - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wheels-${{ matrix.os }}-${{ matrix.target }} path: packages/python/dist - node-publish: name: Node Publish runs-on: ubuntu-latest @@ -405,16 +429,16 @@ jobs: - node-build if: ${{ contains(needs.changepacks.outputs.changepacks, 'packages/node/package.json') }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v8.1.0 - name: Install maturin run: uv pip install maturin --system - name: Install @@ -430,7 +454,7 @@ jobs: # run: bunx napi create-npm-dirs # working-directory: packages/node - name: Download all artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: path: packages/node/pkg/* # - name: Move artifacts @@ -441,10 +465,10 @@ jobs: # working-directory: packages/node - name: Publish run: | - # bun install -g @napi-rs/cli - bun run build - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc - npm publish --access public + # bun install -g @napi-rs/cli + bun run build + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -455,7 +479,6 @@ jobs: # upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/node/package.json'] }} # asset_path: packages/node/pkg/* - python-publish: name: Python Publish runs-on: ubuntu-latest @@ -471,13 +494,13 @@ jobs: # Used to generate artifact attestation attestations: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@v4 with: - subject-path: 'wheels-*/*' + subject-path: "wheels-*/*" - name: Publish to PyPI - uses: PyO3/maturin-action@main + uses: PyO3/maturin-action@v1 env: MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} with: @@ -489,7 +512,7 @@ jobs: uses: owjs3901/upload-github-release-asset@main with: upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/python/pyproject.toml'] }} - asset_path: '*/*.whl' + asset_path: "*/*.whl" upload-assets: needs: changepacks @@ -521,7 +544,7 @@ jobs: binary_name: braillify-darwin-arm64 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -548,7 +571,7 @@ jobs: needs: - changepacks steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Publish to Cargo run: cargo publish -p braillify env: @@ -561,7 +584,7 @@ jobs: needs: - changepacks steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: @@ -575,7 +598,7 @@ jobs: run: | echo "version=$(cargo pkgid -p braillify | sed -E 's/.*#([0-9]+\.[0-9]+\.[0-9]+).*/\1/')" >> $GITHUB_OUTPUT id: version - - uses: vedantmgoyal9/winget-releaser@main + - uses: vedantmgoyal9/winget-releaser@v2 with: identifier: Braillify.Braillify installers-regex: '\.exe$' # Only .exe files @@ -626,7 +649,7 @@ jobs: rid: osx-arm64 lib_name: libbraillify_native.dylib steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -646,7 +669,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dotnet-native-${{ matrix.rid }} path: target/${{ matrix.target }}/release/${{ matrix.lib_name }} @@ -663,15 +686,15 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Download all native artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: native-artifacts pattern: dotnet-native-* @@ -719,7 +742,193 @@ jobs: dotnet nuget push packages/dotnet/Braillify/nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Upload NuGet packages as artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget-packages path: packages/dotnet/**/nupkg/*.nupkg + + # go + go-test: + name: Go Test - ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + # Windows x64 + - runner: windows-latest + target: x86_64-pc-windows-gnu + platform: windows-amd64 + # Linux x64 + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform: linux-amd64 + # Linux ARM64 + - runner: ubuntu-latest + target: aarch64-unknown-linux-gnu + platform: linux-arm64 + # macOS ARM64 + - runner: macos-14 + target: aarch64-apple-darwin + platform: darwin-arm64 + # macOS x64 + - runner: macos-13 + target: x86_64-apple-darwin + platform: darwin-amd64 + steps: + # pull_request_target에서도 PR 코드를 체크아웃해 테스트한다. + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.platform == 'linux-arm64' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Build native library + run: cargo build --release --target ${{ matrix.target }} -p braillify-go + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Copy native library + run: | + mkdir -p packages/go/libs/${{ matrix.platform }} + cp target/${{ matrix.target }}/release/libbraillify_go.a packages/go/libs/${{ matrix.platform }}/ + shell: bash + + - name: Test + run: go test -v ./... + working-directory: packages/go + + go-build: + name: Go Build - ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + needs: + - test + - changepacks + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + contains(needs.changepacks.outputs.changepacks, 'packages/go/Cargo.toml') + strategy: + fail-fast: false + matrix: + include: + # Windows x64 + - runner: windows-latest + target: x86_64-pc-windows-gnu + platform: windows-amd64 + # Linux x64 + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform: linux-amd64 + # Linux ARM64 + - runner: ubuntu-latest + target: aarch64-unknown-linux-gnu + platform: linux-arm64 + # macOS ARM64 + - runner: macos-14 + target: aarch64-apple-darwin + platform: darwin-arm64 + # macOS x64 + - runner: macos-13 + target: x86_64-apple-darwin + platform: darwin-amd64 + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.platform == 'linux-arm64' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build native library + run: cargo build --release --target ${{ matrix.target }} -p braillify-go + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: go-lib-${{ matrix.platform }} + path: target/${{ matrix.target }}/release/libbraillify_go.a + if-no-files-found: error + + go-publish: + name: Go Publish + runs-on: ubuntu-latest + needs: + - changepacks + - go-build + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + contains(needs.changepacks.outputs.changepacks, 'packages/go/Cargo.toml') + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Extract version + id: version + working-directory: packages/go + run: | + v=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([0-9.]+)".*/\1/') + echo "version=$v" >> $GITHUB_OUTPUT + echo "Resolved version: $v" + + - name: Download platform libs + uses: actions/download-artifact@v8 + with: + pattern: go-lib-* + path: libs-raw + merge-multiple: false + + - name: Place libs + run: | + for d in libs-raw/go-lib-*; do + [ -d "$d" ] || continue + plat="${d##*go-lib-}" + mkdir -p "packages/go/libs/$plat" + cp "$d/libbraillify_go.a" "packages/go/libs/$plat/" + done + echo "=== Placed libs ===" + find packages/go/libs -type f + + - name: Commit libs + new module tag + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + TAG="packages/go/v${{ steps.version.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists — skip (no force-move)." + exit 0 + fi + git add -f packages/go/libs/ + git commit -m "chore(go): publish static libs v${{ steps.version.outputs.version }} [skip ci]" + git pull --rebase origin main + git tag "$TAG" + git push origin main + git push origin "$TAG" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8f27dc08..840fdf3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,13 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "braillify-go" +version = "2.0.0" +dependencies = [ + "braillify", +] + [[package]] name = "bstr" version = "1.12.1" diff --git a/packages/go/.gitignore b/packages/go/.gitignore new file mode 100644 index 00000000..46e1819d --- /dev/null +++ b/packages/go/.gitignore @@ -0,0 +1,4 @@ +*.test +*.exe +*.out +libs/ diff --git a/packages/go/Cargo.toml b/packages/go/Cargo.toml new file mode 100644 index 00000000..1a17dae8 --- /dev/null +++ b/packages/go/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "braillify-go" +version = "2.0.0" +edition = "2024" + +[lib] +name = "braillify_go" +crate-type = ["cdylib", "staticlib"] + +[dependencies] +braillify = { path = "../../libs/braillify", default-features = false } diff --git a/packages/go/braillify.go b/packages/go/braillify.go new file mode 100644 index 00000000..597e1d25 --- /dev/null +++ b/packages/go/braillify.go @@ -0,0 +1,16 @@ +package braillify + +// Encode converts Korean text to braille byte representation. +func Encode(text string) ([]byte, error) { + return cEncode(text) +} + +// EncodeToUnicode converts Korean text to braille Unicode string. +func EncodeToUnicode(text string) (string, error) { + return cEncodeToUnicode(text) +} + +// EncodeToBrailleFont converts Korean text to braille font string. +func EncodeToBrailleFont(text string) (string, error) { + return cEncodeToBrailleFont(text) +} diff --git a/packages/go/braillify_test.go b/packages/go/braillify_test.go new file mode 100644 index 00000000..e12bdfab --- /dev/null +++ b/packages/go/braillify_test.go @@ -0,0 +1,51 @@ +package braillify + +import "testing" + +func TestEncodeToUnicode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"안녕하세요", "⠣⠒⠉⠻⠚⠠⠝⠬"}, + {"상상이상의", "⠇⠶⠇⠶⠕⠇⠶⠺"}, + {"1,000", "⠼⠁⠂⠚⠚⠚"}, + {"ATM", "⠠⠠⠁⠞⠍"}, + {"", ""}, + } + + for _, tt := range tests { + result, err := EncodeToUnicode(tt.input) + if err != nil { + t.Errorf("EncodeToUnicode(%q): unexpected error: %v", tt.input, err) + continue + } + t.Logf("EncodeToUnicode(%q) = %q", tt.input, result) + if result != tt.expected { + t.Errorf("EncodeToUnicode(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestEncode(t *testing.T) { + result, err := Encode("안녕") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Logf("Encode(%q) = %v", "안녕", result) + if len(result) == 0 { + t.Error("expected non-empty byte slice") + } +} + +func TestEncodeToBrailleFont(t *testing.T) { + result, err := EncodeToBrailleFont("안녕하세요") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "⠣⠒⠉⠻⠚⠠⠝⠬" + t.Logf("EncodeToBrailleFont(%q) = %q", "안녕하세요", result) + if result != expected { + t.Errorf("EncodeToBrailleFont = %q, want %q", result, expected) + } +} diff --git a/packages/go/cgo.go b/packages/go/cgo.go new file mode 100644 index 00000000..3048345d --- /dev/null +++ b/packages/go/cgo.go @@ -0,0 +1,85 @@ +package braillify + +/* +#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/libs/darwin-amd64 -lbraillify_go -lm -lpthread +#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/libs/darwin-arm64 -lbraillify_go -lm -lpthread +#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/libs/linux-amd64 -lbraillify_go -lm -lpthread -ldl +#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/libs/linux-arm64 -lbraillify_go -lm -lpthread -ldl +#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/libs/windows-amd64 -lbraillify_go -lntdll -lws2_32 -lbcrypt -ladvapi32 -luserenv + +#include +#include +#include + +extern uint8_t* braillify_encode(const char* text, size_t* out_len); +extern char* braillify_encode_to_unicode(const char* text); +extern char* braillify_encode_to_braille_font(const char* text); +extern char* braillify_get_last_error(); +extern void braillify_free_string(char* ptr); +extern void braillify_free_bytes(uint8_t* ptr, size_t len); +*/ +import "C" + +import ( + "errors" + "runtime" + "unsafe" +) + +func cEncode(text string) ([]byte, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + var outLen C.size_t + result := C.braillify_encode(cText, &outLen) + if result == nil { + return nil, getLastError() + } + defer C.braillify_free_bytes(result, outLen) + + return C.GoBytes(unsafe.Pointer(result), C.int(outLen)), nil +} + +func cEncodeToUnicode(text string) (string, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + result := C.braillify_encode_to_unicode(cText) + if result == nil { + return "", getLastError() + } + defer C.braillify_free_string(result) + + return C.GoString(result), nil +} + +func cEncodeToBrailleFont(text string) (string, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + result := C.braillify_encode_to_braille_font(cText) + if result == nil { + return "", getLastError() + } + defer C.braillify_free_string(result) + + return C.GoString(result), nil +} + +func getLastError() error { + errPtr := C.braillify_get_last_error() + if errPtr == nil { + return errors.New("braillify: unknown error") + } + defer C.braillify_free_string(errPtr) + return errors.New(C.GoString(errPtr)) +} diff --git a/packages/go/go.mod b/packages/go/go.mod new file mode 100644 index 00000000..d54c11dd --- /dev/null +++ b/packages/go/go.mod @@ -0,0 +1,3 @@ +module github.com/dev-five-git/braillify/packages/go + +go 1.21 diff --git a/packages/go/src/lib.rs b/packages/go/src/lib.rs new file mode 100644 index 00000000..81d07499 --- /dev/null +++ b/packages/go/src/lib.rs @@ -0,0 +1,303 @@ +use std::cell::RefCell; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_last_error(err: String) { + LAST_ERROR.with(|e| { + *e.borrow_mut() = Some(err); + }); +} + +fn clear_last_error() { + LAST_ERROR.with(|e| { + *e.borrow_mut() = None; + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn braillify_get_last_error() -> *mut c_char { + LAST_ERROR.with(|e| match e.borrow().as_ref() { + // Error messages never contain null bytes, so unwrap_or(null) + // is defensive dead code. + Some(msg) => CString::new(msg.clone()) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + None => ptr::null_mut(), + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode(text: *const c_char, out_len: *mut usize) -> *mut u8 { + clear_last_error(); + + if text.is_null() || out_len.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + match braillify::encode(text_str) { + Ok(result) => { + unsafe { *out_len = result.len() }; + let boxed = result.into_boxed_slice(); + Box::into_raw(boxed) as *mut u8 + } + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_to_unicode(text: *const c_char) -> *mut c_char { + clear_last_error(); + + if text.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + // CString::new() cannot fail here: braille output only contains + // Unicode characters in U+2800..U+28FF, never null bytes. + // The Err branch is defensive dead code. + match braillify::encode_to_unicode(text_str) { + Ok(result) => match CString::new(result) { + Ok(c_string) => c_string.into_raw(), + Err(e) => { + set_last_error(format!("CString conversion error: {}", e)); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_to_braille_font(text: *const c_char) -> *mut c_char { + clear_last_error(); + + if text.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + // CString::new() cannot fail here: braille output only contains + // Unicode characters in U+2800..U+28FF, never null bytes. + // The Err branch is defensive dead code. + match braillify::encode_to_braille_font(text_str) { + Ok(result) => match CString::new(result) { + Ok(c_string) => c_string.into_raw(), + Err(e) => { + set_last_error(format!("CString conversion error: {}", e)); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { + drop(CString::from_raw(ptr)); + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_free_bytes(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + unsafe { + let _ = Vec::from_raw_parts(ptr, len, len); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + use std::ptr; + + #[test] + fn test_encode_to_unicode() { + let input = CString::new("안녕하세요").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), "⠣⠒⠉⠻⠚⠠⠝⠬"); + } + + #[test] + fn test_encode_to_unicode_empty() { + let input = CString::new("").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), ""); + } + + #[test] + fn test_encode_to_unicode_null() { + let result = unsafe { braillify_encode_to_unicode(ptr::null()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font() { + let input = CString::new("안녕하세요").unwrap(); + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), "⠣⠒⠉⠻⠚⠠⠝⠬"); + } + + #[test] + fn test_encode_to_braille_font_null() { + let result = unsafe { braillify_encode_to_braille_font(ptr::null()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode() { + let input = CString::new("안녕").unwrap(); + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(!result.is_null()); + assert!(out_len > 0); + unsafe { braillify_free_bytes(result, out_len) }; + } + + #[test] + fn test_encode_null_text() { + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(ptr::null(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_null_out_len() { + let input = CString::new("test").unwrap(); + let result = unsafe { braillify_encode(input.as_ptr(), ptr::null_mut()) }; + assert!(result.is_null()); + } + + #[test] + fn test_get_last_error_after_null() { + let _ = unsafe { braillify_encode_to_unicode(ptr::null()) }; + let err = braillify_get_last_error(); + assert!(!err.is_null()); + let err_str = unsafe { CString::from_raw(err) }; + assert!(err_str.to_str().unwrap().contains("Null pointer")); + } + + #[test] + fn test_free_string_null() { + unsafe { braillify_free_string(ptr::null_mut()) }; + } + + #[test] + fn test_free_bytes_null() { + unsafe { braillify_free_bytes(ptr::null_mut(), 0) }; + } + + #[test] + fn test_encode_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_unicode_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_free_string_non_null() { + let s = CString::new("test").unwrap(); + let ptr = s.into_raw(); + unsafe { braillify_free_string(ptr) }; + } + + #[test] + fn test_get_last_error_none() { + let input = CString::new("a").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + unsafe { braillify_free_string(result) }; + let err = braillify_get_last_error(); + assert!(err.is_null()); + } + + #[test] + fn test_encode_invalid_char() { + let input = CString::new("😀").unwrap(); + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_unicode_invalid_char() { + let input = CString::new("😀").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font_invalid_char() { + let input = CString::new("😀").unwrap(); + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(result.is_null()); + } +}