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

Generated by: LCOV version 2.0-1