Skip to content

fix: align scanner semantics with vscode-oniguruma (tie-breaking, CAPTURE_GROUP, UTF-16 offsets) - #244

Merged
skiniks merged 1 commit into
skiniks:mainfrom
tinglinzh:fix/textmate-scanner-semantics
Jun 6, 2026
Merged

fix: align scanner semantics with vscode-oniguruma (tie-breaking, CAPTURE_GROUP, UTF-16 offsets)#244
skiniks merged 1 commit into
skiniks:mainfrom
tinglinzh:fix/textmate-scanner-semantics

Conversation

@tinglinzh

@tinglinzh tinglinzh commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #243

Aligns findNextMatchSync semantics with the reference vscode-oniguruma implementation. Three divergences caused visibly wrong token scopes/colors with real-world grammars (details, screenshots and reference-source quotes in #243):

1. Tie-breaking: lowest pattern index, not longest match (onig_regex.cpp)

TextMate contract: leftmost match wins; ties at the same position are won by the lowest pattern index (rule order = rule priority). The previous length-based tie-break let later rules steal matches from higher-priority rules, flipping scopes depending on token length. Also adds the reference early-exit once a match starts exactly at startPosition (no later pattern can do better, ties keep the earlier pattern) — same as the if (location == position) break; in vscode-oniguruma's onig.cc.

2. Compile patterns with ONIG_OPTION_CAPTURE_GROUP (onig_regex.cpp)

With ONIG_OPTION_DEFAULT, oniguruma disables numbered captures in any pattern that also contains named groups, silently breaking TextMate captures: {1: ..., 2: ...} scope assignment. vscode-oniguruma defaults to FindOption.CaptureGroup.

3. UTF-16 ⇄ UTF-8 offset conversion in the JSI bridge (NativeShikiEngineModule.cpp)

vscode-textmate passes/expects offsets in UTF-16 code units; oniguruma scans the UTF-8 buffer and reports byte offsets. The bridge now:

  • converts the incoming startPosition from UTF-16 code units to a byte offset, and
  • converts all returned capture start/end byte offsets back to UTF-16 (negative offsets of unmatched optional groups pass through).

Surrogate pairs (4-byte UTF-8 sequences → 2 UTF-16 code units) are handled. The byte→UTF-16 table is built per call, O(line length); mirrors UtfString / convertUtf8OffsetToUtf16 in vscode-oniguruma.

Testing

  • Running this exact patch locally via pnpm patch on iOS (RN 0.83.6, new arch, @shikijs/core 3.23.0, html/javascript grammars, one-light/one-dark-pro themes) against the chat transcripts that exposed the bug; the semantics of all three changes are taken 1:1 from vscode-oniguruma (onig.cc scanner loop, FindOption.CaptureGroup default, UtfString offset conversion).
  • Behavior for pure-ASCII input with no position ties is unchanged.
  • A good regression test would be diffing codeToTokensBase output between this engine and @shikijs/engine-oniguruma (WASM) over the bundled language samples — happy to add one if you can point me at the preferred test setup.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

Bug Fixes

  • Fixed text offset conversion in pattern matching to properly handle multi-byte character encodings
  • Updated pattern selection strategy to correctly prioritize matches by position and pattern order

Three divergences from the reference vscode-oniguruma implementation
caused incorrect token scopes / colors in real-world grammars:

1. Tie-breaking across patterns used match length. The TextMate
   contract is: leftmost match wins, ties are won by the lowest
   pattern index (rule order = rule priority). Tie-breaking by
   length lets later rules steal matches, producing inconsistent
   scopes for identical constructs (e.g. some object keys colored,
   others not). Also adds the reference early-exit when a match
   starts exactly at startPosition.

2. Patterns were compiled with ONIG_OPTION_DEFAULT. Oniguruma then
   disables numbered captures in any pattern containing named groups,
   silently breaking TextMate `captures: {1: ..., 2: ...}` scope
   assignment. vscode-oniguruma compiles with
   ONIG_OPTION_CAPTURE_GROUP by default.

3. findNextMatchSync received UTF-16 code unit offsets from
   vscode-textmate but passed them to oniguruma as UTF-8 byte
   offsets, and returned byte offsets back unconverted. Any
   multi-byte character (CJK, emoji) shifted every following token
   boundary on the line. vscode-oniguruma converts in both
   directions (UtfString / convertUtf8OffsetToUtf16); this adds the
   equivalent conversion in the JSI bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes three divergences between the scanner implementation and the vscode-oniguruma reference: match-selection now uses pattern-index tie-breaking instead of match length, capture groups are preserved via ONIG_OPTION_CAPTURE_GROUP, and UTF-16/UTF-8 offset conversion is bidirectional to handle multi-byte characters correctly.

Changes

Scanner semantics alignment with vscode-oniguruma

Layer / File(s) Summary
Match selection algorithm and capture group configuration
packages/react-native-shiki-engine/cpp/onig_regex.cpp
find_next_match replaces "leftmost-longest" tie-breaking with "leftmost, then lowest pattern index" to match TextMate semantics. Regex compilation now explicitly enables ONIG_OPTION_CAPTURE_GROUP to preserve numbered captures. Early-exit condition stops the search loop when a match is found at the search start position.
UTF-8/UTF-16 offset conversion
packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp
Adds bidirectional offset converters (buildByteToUtf16Table, utf16ToByteOffset, byteToUtf16Offset) to reconcile UTF-16 code-unit offsets from JavaScript with UTF-8 byte offsets used by oniguruma. Input startPosition is converted to bytes before find_next_match; output capture offsets are converted back to UTF-16 (preserving negative values for unmatched optional groups).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • skiniks/react-native-shiki-engine#221: Fixes capture_count/capture_indices sizing to prevent out-of-bounds reads; this PR adjusts how returned capture offsets are marshaled, so the capture data handling pipeline is directly impacted.

Suggested reviewers

  • skiniks

Poem

🐰 A scanner's true north now shines so clear,
UTF-16, UTF-8—no more tears!
Pattern index wins when matches tie,
Capture groups preserved—no more goodbye!
Tokens colored true from start to end.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes all three main changes: tie-breaking semantics, CAPTURE_GROUP option, and UTF-16 offset handling, directly matching the PR's stated objectives.
Linked Issues check ✅ Passed Code changes in both files directly address all three requirements from issue #243: tie-breaking strategy changed to TextMate semantics [onig_regex.cpp], ONIG_OPTION_CAPTURE_GROUP enabled [onig_regex.cpp], and UTF-16⇄UTF-8 offset conversion implemented [NativeShikiEngineModule.cpp].
Out of Scope Changes check ✅ Passed All modifications are scoped to the three specific fixes identified in issue #243; no unrelated refactoring or extraneous changes are present in either modified file.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp

In file included from packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp:1:
packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.h:3:10: fatal error: 'ReactCommon/CallInvoker.h' file not found
3 | #include <ReactCommon/CallInvoker.h>
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
Aborting translation of method 'facebook::react::NativeShikiEngineModule::findNextMatchSync' in file 'packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, ch

... [truncated 2200 characters] ...

ors.ml", line 48, characters 6-141
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.add_method in file "src/clang/cFrontend_decl.ml" (inlined), line 54, characters 4-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl.add_method_if_create_procdesc in file "src/clang/cFrontend_decl.ml" (inlined), line 123, characters 16-158
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl in file "src/clang/cFrontend_decl.ml", line 126, characters 17-97
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_methods in file "src/clang/cFrontend_decl.ml" (inlined), line 270, characters 8-122
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 12-15
Called from Stdlib__List.iter in file

packages/react-native-shiki-engine/cpp/onig_regex.cpp

In file included from packages/react-native-shiki-engine/cpp/onig_regex.cpp:1:
packages/react-native-shiki-engine/cpp/onig_regex.h:4:10: fatal error: 'oniguruma.h' file not found
4 | #include <oniguruma.h>
| ^~~~~~~~~~~~~
1 error generated.
Aborting translation of method 'cleanup_cache' in file 'packages/react-native-shiki-engine/cpp/onig_regex.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "src/list.ml", line 944, characters 13-17
Called from Base__L

... [truncated 2200 characters] ...

/cFrontend_decl.ml" (inlined), line 54, characters 4-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.function_decl in file "src/clang/cFrontend_decl.ml", line 90, characters 12-151
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.translate_one_declaration in file "src/clang/cFrontend_decl.ml", line 453, characters 10-56
Called from Stdlib__List.iter in file "list.ml", line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 17-25
Called from Base__List0.iter in file "src/list0.ml" (inlined), line 25, characters 16-35
Called from ClangFrontend__CFrontend.compute_icfg in file "src/clang/cFrontend.ml", line 28, characters 6-130
Called from ClangFrontend__Capture.run_clang_frontend in file "src/clang/Capture.ml",


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp (1)

53-65: 💤 Low value

Consider binary search for better performance on long lines.

The linear scan is O(n) where n is the string length. Since the table is monotonically non-decreasing, binary search would reduce this to O(log n). While typical line lengths are short, lines with many multi-byte characters could benefit from this optimization.

♻️ Optional binary search implementation
 static int utf16ToByteOffset(const std::vector<int>& table, int utf16Offset) {
   if (utf16Offset <= 0) {
     return 0;
   }
-  // table is monotonically non-decreasing; find first byte whose utf16 >= target
   const int n = static_cast<int>(table.size()) - 1;
-  for (int b = 0; b <= n; b++) {
-    if (table[b] >= utf16Offset) {
-      return b;
-    }
-  }
-  return n;
+  // Binary search for first byte whose utf16 offset >= target
+  int lo = 0, hi = n;
+  while (lo < hi) {
+    int mid = lo + (hi - lo) / 2;
+    if (table[mid] < utf16Offset) {
+      lo = mid + 1;
+    } else {
+      hi = mid;
+    }
+  }
+  return lo;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp` around
lines 53 - 65, The utf16ToByteOffset function currently does a linear scan over
table to find the first byte index where table[b] >= utf16Offset; replace that
O(n) scan with a binary search (e.g., classic lower_bound style) over the
monotonic vector<int> table to return the first index b meeting table[b] >=
utf16Offset, still handling utf16Offset <= 0 (return 0) and the case where no
entry meets the condition (return last index n). Update utf16ToByteOffset to use
binary search logic while preserving existing return semantics and types.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp`:
- Around line 53-65: The utf16ToByteOffset function currently does a linear scan
over table to find the first byte index where table[b] >= utf16Offset; replace
that O(n) scan with a binary search (e.g., classic lower_bound style) over the
monotonic vector<int> table to return the first index b meeting table[b] >=
utf16Offset, still handling utf16Offset <= 0 (return 0) and the case where no
entry meets the condition (return last index n). Update utf16ToByteOffset to use
binary search logic while preserving existing return semantics and types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d27eab63-fa71-4ef5-8885-fb546d1b39aa

📥 Commits

Reviewing files that changed from the base of the PR and between 32b934e and 95c9e35.

📒 Files selected for processing (2)
  • packages/react-native-shiki-engine/cpp/NativeShikiEngineModule.cpp
  • packages/react-native-shiki-engine/cpp/onig_regex.cpp

@skiniks
skiniks merged commit 6704e59 into skiniks:main Jun 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scanner semantics diverge from vscode-oniguruma: length-based tie-breaking, missing ONIG_OPTION_CAPTURE_GROUP, UTF-8 byte vs UTF-16 offsets

2 participants