LCOV - code coverage report
Current view: top level - src - icu_tokenizer.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 93.7 % 63 59
Test Date: 2026-08-24 05:12:17 Functions: - 0 0

            Line data    Source code
       1              : // Copyright 2026 The Authors
       2              : //
       3              : // Licensed under the Apache License, Version 2.0 (the "License");
       4              : // you may not use this file except in compliance with the License.
       5              : // You may obtain a copy of the License at
       6              : //
       7              : //     https://www.apache.org/licenses/LICENSE-2.0
       8              : //
       9              : // Unless required by applicable law or agreed to in writing, software
      10              : // distributed under the License is distributed on an "AS IS" BASIS,
      11              : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12              : // See the License for the specific language governing permissions and
      13              : // limitations under the License.
      14              : 
      15              : import 'dart:ffi';
      16              : import 'dart:io';
      17              : 
      18              : import 'package:ffi/ffi.dart';
      19              : 
      20              : import 'tokenizer.dart';
      21              : 
      22              : // ---------------------------------------------------------------------------
      23              : // ICU constants
      24              : // ---------------------------------------------------------------------------
      25              : 
      26              : /// UBreakIteratorType value for word boundary analysis.
      27              : const int _ubrkWord = 2;
      28              : 
      29              : /// Sentinel returned by ubrk_next() when iteration is complete.
      30              : ///
      31              : /// ICU defines UBRK_DONE as (int32_t)0xFFFFFFFF — i.e. -1 in signed form.
      32              : const int _ubrkDone = -1;
      33              : 
      34              : // ---------------------------------------------------------------------------
      35              : // Native function typedefs
      36              : // ---------------------------------------------------------------------------
      37              : 
      38              : /// `ubrk_open` — allocates a UBreakIterator.
      39              : ///
      40              : /// Passing address 0 for [locale] selects ICU's default (root) locale, which
      41              : /// is sufficient for script-level word boundary rules.
      42              : typedef _UbrkOpenNative = Pointer<Void> Function(
      43              :   Int32 type,
      44              :   Pointer<Utf8> locale,
      45              :   Pointer<Uint16> text,
      46              :   Int32 textLength,
      47              :   Pointer<Int32> status,
      48              : );
      49              : typedef _UbrkOpen = Pointer<Void> Function(
      50              :   int type,
      51              :   Pointer<Utf8> locale,
      52              :   Pointer<Uint16> text,
      53              :   int textLength,
      54              :   Pointer<Int32> status,
      55              : );
      56              : 
      57              : /// `ubrk_next` — advance to the next boundary; returns position or [_ubrkDone].
      58              : typedef _UbrkNextNative = Int32 Function(Pointer<Void> bi);
      59              : typedef _UbrkNext = int Function(Pointer<Void> bi);
      60              : 
      61              : /// `ubrk_close` — release the UBreakIterator.
      62              : typedef _UbrkCloseNative = Void Function(Pointer<Void> bi);
      63              : typedef _UbrkClose = void Function(Pointer<Void> bi);
      64              : 
      65              : // ---------------------------------------------------------------------------
      66              : // Library loader
      67              : // ---------------------------------------------------------------------------
      68              : 
      69              : /// Opens the system ICU library appropriate for the current platform.
      70              : ///
      71              : /// ICU is bundled with every target OS supported by this package:
      72              : ///
      73              : /// | Platform    | Library                              |
      74              : /// |-------------|--------------------------------------|
      75              : /// | macOS / iOS | libicucore.dylib  (ships with OS)    |
      76              : /// | Android     | libicuuc.so       (NDK)              |
      77              : /// | Linux       | libicuuc.so.NN    (widely packaged)  |
      78              : /// | Windows     | icu.dll           (Windows 10+)      |
      79              : ///
      80              : /// [platform] defaults to [Platform.operatingSystem]. Pass an explicit value
      81              : /// to exercise non-native library-loading paths in tests.
      82              : ///
      83              : /// Throws [UnsupportedError] if no matching library can be found.
      84            2 : DynamicLibrary _openIcuLibrary([String? platform]) {
      85            2 :   platform ??= Platform.operatingSystem;
      86              : 
      87            4 :   if (platform == 'macos' || platform == 'ios') {
      88            1 :     return DynamicLibrary.open('libicucore.dylib');
      89              :   }
      90              : 
      91            2 :   if (platform == 'android') {
      92            0 :     return DynamicLibrary.open('libicuuc.so');
      93              :   }
      94              : 
      95            2 :   if (platform == 'linux') {
      96              :     // ubrk_open and other break-iterator symbols live in libicuuc (Common),
      97              :     // not libicui18n. The unversioned symlink requires the -dev package;
      98              :     // fall back through versioned names common across distributions.
      99              :     const candidates = [
     100              :       'libicuuc.so',
     101              :       'libicuuc.so.76',
     102              :       'libicuuc.so.74',
     103              :       'libicuuc.so.73',
     104              :       'libicuuc.so.72',
     105              :       'libicuuc.so.70',
     106              :       'libicuuc.so.67',
     107              :       'libicuuc.so.66',
     108              :     ];
     109            4 :     for (final name in candidates) {
     110              :       try {
     111            2 :         return DynamicLibrary.open(name);
     112              :       } catch (_) {
     113              :         // try next candidate
     114              :       }
     115              :     }
     116            0 :     throw UnsupportedError(
     117              :       'Could not find libicuuc on this Linux system. '
     118              :       'Install libicu-dev (Debian/Ubuntu) or icu (Arch/Fedora).',
     119              :     );
     120              :   }
     121              : 
     122            1 :   if (platform == 'windows') {
     123              :     const candidates = ['icu.dll', 'icuuc.dll'];
     124            2 :     for (final name in candidates) {
     125              :       try {
     126            1 :         return DynamicLibrary.open(name);
     127              :       } catch (_) {
     128              :         // try next candidate
     129              :       }
     130              :     }
     131            1 :     throw UnsupportedError('Could not find ICU DLL on this Windows system.');
     132              :   }
     133              : 
     134            2 :   throw UnsupportedError('IcuTokenizer is not supported on $platform.');
     135              : }
     136              : 
     137              : // ---------------------------------------------------------------------------
     138              : // ICU symbol-suffix resolver
     139              : // ---------------------------------------------------------------------------
     140              : 
     141              : /// Returns the version suffix appended to ICU symbols on this system, or `''`.
     142              : ///
     143              : /// Some distributions (older Debian/Ubuntu) disable ICU symbol renaming so
     144              : /// `ubrk_open` is exported as-is. Others (Fedora, Debian Trixie+) use ICU's
     145              : /// default renaming, which appends the major version number (e.g. `_76`).
     146            2 : String _icuSymbolSuffix(DynamicLibrary lib) {
     147              :   const versionsToTry = [0, 76, 75, 74, 73, 72, 71, 70, 69, 68, 67, 66, 65, 64];
     148            4 :   for (final v in versionsToTry) {
     149            4 :     final suffix = v == 0 ? '' : '_$v';
     150              :     try {
     151            4 :       lib.lookup<NativeFunction<_UbrkOpenNative>>('ubrk_open$suffix');
     152              :       return suffix;
     153              :     } catch (_) {
     154              :       // try next
     155              :     }
     156              :   }
     157            0 :   throw UnsupportedError(
     158              :     'Could not find ubrk_open[_NN] in the loaded ICU library. '
     159              :     'The library may be incomplete or use an unsupported symbol renaming scheme.',
     160              :   );
     161              : }
     162              : 
     163              : // ---------------------------------------------------------------------------
     164              : // IcuTokenizer
     165              : // ---------------------------------------------------------------------------
     166              : 
     167              : /// An [OffsetTokenizer] backed by the ICU C library's UBRK_WORD break
     168              : /// iterator.
     169              : ///
     170              : /// Conforms to UAX #29 Unicode Text Segmentation and handles non-Latin scripts
     171              : /// (CJK, Thai, Arabic, etc.) correctly. This is the preferred implementation
     172              : /// for multi-language use cases.
     173              : ///
     174              : /// ## Deployment
     175              : ///
     176              : /// ICU is a system library on all of this package's target platforms — no
     177              : /// bundling is required and there is no App Store risk:
     178              : ///
     179              : /// | Platform    | Library                              |
     180              : /// |-------------|--------------------------------------|
     181              : /// | macOS / iOS | libicucore.dylib  (ships with OS)    |
     182              : /// | Android     | libicuuc.so       (NDK)              |
     183              : /// | Linux       | libicuuc.so.NN    (widely packaged)  |
     184              : /// | Windows     | icu.dll           (Windows 10+)      |
     185              : ///
     186              : /// ## Platform note — ubrk_getRuleStatus
     187              : ///
     188              : /// Apple's libicucore does not include UAX #29 rule-status tags in its
     189              : /// compiled word break rules, so `ubrk_getRuleStatus()` returns non-standard
     190              : /// values on macOS/iOS. This implementation therefore uses Dart's own Unicode
     191              : /// `RegExp` for span classification rather than relying on rule-status codes.
     192              : /// Boundary *positions* from the ICU iterator are correct on all platforms.
     193              : ///
     194              : /// Construct once and reuse — the FFI bindings are resolved at construction
     195              : /// time. Each call to [tokeniseSpans] (which [tokenise] delegates to)
     196              : /// allocates a temporary native UTF-16 buffer and releases it before
     197              : /// returning.
     198              : ///
     199              : /// Throws [UnsupportedError] if the ICU library cannot be found or if the
     200              : /// required symbols are absent.
     201              : class IcuTokenizer implements OffsetTokenizer {
     202              :   // Retain the DynamicLibrary reference to prevent the OS from unloading the
     203              :   // library while this tokenizer is alive.
     204              :   // ignore: unused_field
     205              :   final DynamicLibrary _lib;
     206              :   final _UbrkOpen _ubrkOpen;
     207              :   final _UbrkNext _ubrkNext;
     208              :   final _UbrkClose _ubrkClose;
     209              : 
     210              :   /// Opens the system ICU library and resolves the FFI symbols.
     211              :   ///
     212              :   /// Throws [UnsupportedError] if the library cannot be found on this platform.
     213            6 :   factory IcuTokenizer() => IcuTokenizer._fromLib(_openIcuLibrary());
     214              : 
     215              :   /// Creates an [IcuTokenizer] that loads the ICU library for [platform].
     216              :   ///
     217              :   /// [platform] must be a [Platform.operatingSystem] string such as `'linux'`,
     218              :   /// `'android'`, or `'windows'`. This constructor lets tests exercise each
     219              :   /// library-loading branch on a development machine without requiring the
     220              :   /// native platform.
     221            1 :   factory IcuTokenizer.forPlatform(String platform) =>
     222            1 :       IcuTokenizer._fromLib(_openIcuLibrary(platform));
     223              : 
     224            2 :   factory IcuTokenizer._fromLib(DynamicLibrary lib) {
     225            2 :     final s = _icuSymbolSuffix(lib);
     226            2 :     return IcuTokenizer._(
     227              :       lib,
     228            2 :       lib.lookupFunction<_UbrkOpenNative, _UbrkOpen>('ubrk_open$s'),
     229            2 :       lib.lookupFunction<_UbrkNextNative, _UbrkNext>('ubrk_next$s'),
     230            2 :       lib.lookupFunction<_UbrkCloseNative, _UbrkClose>('ubrk_close$s'),
     231              :     );
     232              :   }
     233              : 
     234            2 :   IcuTokenizer._(this._lib, this._ubrkOpen, this._ubrkNext, this._ubrkClose);
     235              : 
     236              :   // Matches any Unicode letter or digit — used to classify ICU spans.
     237              :   //
     238              :   // NOTE: ubrk_getRuleStatus() is NOT used for span classification. Apple's
     239              :   // libicucore does not include the UAX #29 rule-status tags in its compiled
     240              :   // word break rules, so the function returns 0 for all letter/number spans
     241              :   // and non-zero for certain whitespace sequences — the inverse of the
     242              :   // upstream ICU convention. Character-based classification is both more
     243              :   // portable and simpler.
     244            6 :   static final _hasWordChar = RegExp(r'[\p{L}\p{N}]', unicode: true);
     245              : 
     246              :   // Strips leading/trailing non-letter/non-digit characters from a span.
     247              :   // Some ICU builds (including Apple's) group adjacent punctuation into the
     248              :   // same span as the word (e.g. "Hello," rather than "Hello" + ",").
     249            6 :   static final _leadingNonWord = RegExp(r'^[^\p{L}\p{N}]+', unicode: true);
     250            6 :   static final _trailingNonWord = RegExp(r'[^\p{L}\p{N}]+$', unicode: true);
     251              : 
     252            2 :   @override
     253              :   List<String> tokenise(String text) =>
     254           10 :       tokeniseSpans(text).map((s) => s.text).toList(growable: false);
     255              : 
     256            2 :   @override
     257              :   List<TokenSpan> tokeniseSpans(String text) {
     258            2 :     if (text.isEmpty) return const [];
     259              : 
     260            2 :     final codeUnits = text.codeUnits;
     261            2 :     final len = codeUnits.length;
     262              : 
     263              :     // Allocate a native UTF-16 buffer and an error-code cell.
     264              :     final textBuf = calloc<Uint16>(len);
     265              :     final statusBuf = calloc<Int32>();
     266              : 
     267              :     try {
     268              :       // Copy the Dart string's UTF-16 code units into native memory.
     269              :       // Dart strings are UTF-16 encoded; codeUnits gives the correct uint16_t
     270              :       // values for all characters, including supplementary characters encoded
     271              :       // as surrogate pairs.
     272            4 :       for (var i = 0; i < len; i++) {
     273            4 :         textBuf[i] = codeUnits[i];
     274              :       }
     275              : 
     276              :       // Open a UBRK_WORD iterator. Passing address 0 for locale means
     277              :       // "default locale"; for script-level word breaking this is fine.
     278            4 :       final bi = _ubrkOpen(
     279              :         _ubrkWord,
     280            2 :         Pointer<Utf8>.fromAddress(0), // nullptr → default locale
     281              :         textBuf,
     282              :         len,
     283              :         statusBuf,
     284              :       );
     285              : 
     286            4 :       _checkStatus(statusBuf.value, 'ubrk_open');
     287              : 
     288              :       try {
     289            2 :         final spans = <TokenSpan>[];
     290              :         var start = 0;
     291              : 
     292              :         while (true) {
     293            4 :           final end = _ubrkNext(bi);
     294            2 :           if (end == _ubrkDone) break;
     295              : 
     296            2 :           final span = text.substring(start, end);
     297              : 
     298              :           // Include the span only if it contains at least one letter or digit.
     299              :           // Then strip any punctuation that was grouped at either end,
     300              :           // tracking exactly how many characters were trimmed from each side
     301              :           // so the reported offsets describe the trimmed word, not the raw
     302              :           // ICU span. `_leadingNonWord`/`_trailingNonWord` are anchored
     303              :           // (`^`/`$`), so `firstMatch` finds precisely what `replaceFirst`
     304              :           // would have removed.
     305            4 :           if (_hasWordChar.hasMatch(span)) {
     306            6 :             final leadingTrim = _leadingNonWord.firstMatch(span)?.end ?? 0;
     307            4 :             final trailingMatch = _trailingNonWord.firstMatch(span);
     308              :             final trailingTrim = trailingMatch == null
     309              :                 ? 0
     310            6 :                 : span.length - trailingMatch.start;
     311            2 :             final word = span.substring(
     312              :               leadingTrim,
     313            4 :               span.length - trailingTrim,
     314              :             );
     315            2 :             if (word.isNotEmpty) {
     316            2 :               spans.add(
     317            6 :                 TokenSpan(word, start + leadingTrim, end - trailingTrim),
     318              :               );
     319              :             }
     320              :           }
     321              : 
     322              :           start = end;
     323              :         }
     324              : 
     325              :         return spans;
     326              :       } finally {
     327            4 :         _ubrkClose(bi);
     328              :       }
     329              :     } finally {
     330            2 :       calloc.free(textBuf);
     331            2 :       calloc.free(statusBuf);
     332              :     }
     333              :   }
     334              : 
     335              :   /// Throws [StateError] if [statusCode] indicates a fatal ICU error.
     336              :   ///
     337              :   /// ICU's UErrorCode convention (unicode/utypes.h):
     338              :   ///   < 0  warnings (non-fatal — e.g. U_USING_DEFAULT_WARNING = -127)
     339              :   ///   = 0  U_ZERO_ERROR (success)
     340              :   ///   > 0  errors (fatal — e.g. U_ILLEGAL_ARGUMENT_ERROR = 1)
     341            2 :   static void _checkStatus(int statusCode, String fn) {
     342            2 :     if (statusCode > 0) {
     343            0 :       throw StateError('ICU error in $fn: UErrorCode $statusCode');
     344              :     }
     345              :   }
     346              : }
        

Generated by: LCOV version 2.0-1