Skip to content

Commit 2d322f6

Browse files
committed
Add UTF-8 string index search
1 parent 72a08b0 commit 2d322f6

14 files changed

Lines changed: 161 additions & 1 deletion

crates/splitscript-syntax/src/migration.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ pub const CSHARP_STRING_EQUALS_DIAGNOSTIC: MigrationDiagnosticId =
181181
MigrationDiagnosticId::new("csharp.string.equals-call");
182182
pub const CSHARP_STRING_SUBSTRING_DIAGNOSTIC: MigrationDiagnosticId =
183183
MigrationDiagnosticId::new("csharp.string.substring-call");
184+
pub const CSHARP_STRING_INDEX_OF_DIAGNOSTIC: MigrationDiagnosticId =
185+
MigrationDiagnosticId::new("csharp.string.index-of-call");
184186
pub const CSHARP_NUMERIC_PARSE_DIAGNOSTIC: MigrationDiagnosticId =
185187
MigrationDiagnosticId::new("csharp.numeric.static-parse-call");
186188
pub const CSHARP_TIMESPAN_PARSE_DIAGNOSTIC: MigrationDiagnosticId =
@@ -364,6 +366,18 @@ pub const DIAGNOSTICS: &[MigrationDiagnostic] = &[
364366
"there is no automatic rewrite because the compiler cannot prove the source text is ASCII or recover C# overload semantics from the method name alone",
365367
],
366368
},
369+
MigrationDiagnostic {
370+
id: CSHARP_STRING_INDEX_OF_DIAGNOSTIC,
371+
concept: MigrationConceptId::new("string.index-of"),
372+
message: "C# `String.IndexOf` needs an explicit index-model review",
373+
primary_label: "SplitScript returns an optional UTF-8 byte offset",
374+
notes: &[
375+
"rewrite an ordinal ASCII search as `text.indexOf(substring)` and handle `None` instead of comparing the result with C#'s `-1` sentinel",
376+
"SplitScript offsets count UTF-8 bytes; C# string offsets count UTF-16 code units, so copied arithmetic is only equivalent for proven ASCII text",
377+
"comparison-mode and start-index overloads need separate review; the canonical operation is exact and case-sensitive from the beginning of the string",
378+
"there is no automatic rewrite because changing both the index unit and absence representation can require surrounding control-flow changes",
379+
],
380+
},
367381
MigrationDiagnostic {
368382
id: CSHARP_NUMERIC_PARSE_DIAGNOSTIC,
369383
concept: MigrationConceptId::new("string.numeric-parse"),
@@ -406,6 +420,7 @@ pub fn legacy_string_method_diagnostic(name: &str) -> Option<MigrationDiagnostic
406420
match name {
407421
"Equals" => Some(CSHARP_STRING_EQUALS_DIAGNOSTIC),
408422
"Substring" => Some(CSHARP_STRING_SUBSTRING_DIAGNOSTIC),
423+
"IndexOf" => Some(CSHARP_STRING_INDEX_OF_DIAGNOSTIC),
409424
_ => None,
410425
}
411426
}
@@ -776,6 +791,16 @@ pub const CONCEPTS: &[MigrationConcept] = &[
776791
cookbook_anchor: Some("c-string-operations"),
777792
spellings: &[],
778793
},
794+
MigrationConcept {
795+
id: MigrationConceptId::new("string.index-of"),
796+
name: "Substring position",
797+
sources: CSHARP,
798+
support: MigrationSupport::TypedPattern,
799+
summary: "Use `indexOf` for an optional UTF-8 byte offset; review C# UTF-16 index arithmetic and replace the `-1` sentinel with Option handling.",
800+
targets: &[MigrationTarget::StandardLibraryItem("String.indexOf")],
801+
cookbook_anchor: Some("c-string-operations"),
802+
spellings: &[],
803+
},
779804
MigrationConcept {
780805
id: MigrationConceptId::new("string.numeric-parse"),
781806
name: "Numeric string parsing",

docs/ASL_PORTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ For text proven to be ASCII, translate the overload shapes explicitly. C#
111111
For non-ASCII text, first derive UTF-8 byte boundaries rather than copying the
112112
original UTF-16 positions.
113113

114+
C# `value.IndexOf(substring)` returns a UTF-16 code-unit index or `-1`.
115+
SplitScript `value.indexOf(substring)` instead returns a UTF-8 byte offset as
116+
`u32?`; handle `None` directly. The numeric offsets are equivalent only for
117+
text proven to be ASCII:
118+
119+
```splitscript
120+
let separator = current.map.indexOf("_") else return false
121+
```
122+
123+
Comparison-mode and start-index overloads need an explicit rewrite rather than
124+
a method-name substitution.
125+
114126
C# `left.Equals(right)` normally becomes `left == right`; SplitScript compares
115127
strings by exact UTF-8 text rather than GC reference identity. If the source
116128
intentionally ignores ASCII letter case, write

docs/LANGUAGE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,7 @@ is involved:
14251425
| --- | --- |
14261426
| `byteLength()` | UTF-8 byte length |
14271427
| `contains(text)` | Case-sensitive substring test |
1428+
| `indexOf(text)` | First matching UTF-8 byte offset as `u32?` |
14281429
| `startsWith(text)` / `endsWith(text)` | Case-sensitive prefix/suffix tests |
14291430
| `equalsIgnoreAsciiCase(text)` | Equality folding only ASCII letters |
14301431
| `toAsciiLowerCase()` | Lowercase ASCII letters; preserve every other UTF-8 byte |

docs/MIGRATION_CAPABILITIES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This index maps common source-language concepts to canonical SplitScript APIs an
1313
| `string.equality` — String equality | C# | Supported directly | Use `==` or `!=` for exact string content equality; use `equalsIgnoreAsciiCase` only when ASCII-insensitive matching is intended. Canonical targets: `String`, `String.equalsIgnoreAsciiCase`. [Recipe](ASL_PORTING.md#c-string-operations). |
1414
| `operator.strict-equality` — Strict equality operators | JavaScript | Supported directly | Use typed `==` and `!=`; SplitScript has no coercing equality operators, so JavaScript's extra `=` is unnecessary. Canonical targets: `==`, `!=`. |
1515
| `string.substring` — Substring extraction | C# | Use a typed pattern | Use fallible `slice(start, exclusiveEnd)` only after translating C#'s length argument and verifying that UTF-16 source positions are valid UTF-8 byte offsets. Canonical targets: `String.slice`. [Recipe](ASL_PORTING.md#c-string-operations). |
16+
| `string.index-of` — Substring position | C# | Use a typed pattern | Use `indexOf` for an optional UTF-8 byte offset; review C# UTF-16 index arithmetic and replace the `-1` sentinel with Option handling. Canonical targets: `String.indexOf`. [Recipe](ASL_PORTING.md#c-string-operations). |
1617
| `string.numeric-parse` — Numeric string parsing | C# | Supported directly | Replace static Parse/TryParse calls and output parameters with fallible `text.parse()` and ordinary Result handling. Canonical targets: `String.parse`. [Recipe](ASL_PORTING.md#c-string-operations). |
1718
| `type.duration` — Timer durations | C# | Supported directly | Use `Duration` instead of C#'s `TimeSpan`. Canonical targets: `Duration`. |
1819
| `duration.parse` — Text duration parsing | C# | Use a typed pattern | Replace `TimeSpan.Parse` according to whether the input is fixed data or an already-typed timer value; do not preserve culture-sensitive parsing by default. Canonical targets: `Duration.fromWholeSeconds`, `Duration.fromWholeMilliseconds`, `Duration.fromParts`. |

docs/ROADMAP_ARCHIVE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# SplitScript roadmap
22

3+
## 2026-08-09: allocation-free UTF-8 substring positions
4+
5+
- Added `String.indexOf(substring) -> u32?` using the existing allocation-free
6+
runtime search helper. It returns the first UTF-8 byte offset, zero for an
7+
empty needle, and `None` when absent.
8+
- Added runtime coverage including a non-ASCII prefix, proving that the result
9+
is a byte offset rather than a Unicode-scalar or UTF-16 index.
10+
- Added a focused C# `IndexOf` diagnostic with no automatic edit because C#
11+
uses UTF-16 code units and a `-1` sentinel while SplitScript uses byte offsets
12+
and `Option`.
13+
314
## 2026-08-09: composable C# duration-constructor migration
415

516
- Verified that the common C# forms `TimeSpan.FromSeconds(...)` and

src/codegen/expression.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,6 +2129,35 @@ fn compile_expr_unconverted(
21292129
.function(RuntimeHelperId::StringMatch),
21302130
));
21312131
}
2132+
IntrinsicId::StringIndexOf => {
2133+
let found = context.matches.intrinsic_temps[&expression][0];
2134+
let Type::Option(option) = ty else {
2135+
unreachable!("String.indexOf returns the declared optional u32")
2136+
};
2137+
let option_type = context.gc.val_type(Type::Option(option));
2138+
compile_receiver(function, target, context);
2139+
compile_expr(function, args[0], context);
2140+
function
2141+
.instruction(&Instruction::I32Const(0))
2142+
.instruction(&Instruction::Call(
2143+
context
2144+
.runtime_helpers
2145+
.function(RuntimeHelperId::StringFind),
2146+
))
2147+
.instruction(&Instruction::LocalTee(found))
2148+
.instruction(&Instruction::I32Const(0))
2149+
.instruction(&Instruction::I32LtS)
2150+
.instruction(&Instruction::If(BlockType::Result(option_type)))
2151+
.instruction(&Instruction::RefNull(HeapType::Concrete(
2152+
context.gc.index(Type::Option(option)),
2153+
)))
2154+
.instruction(&Instruction::Else)
2155+
.instruction(&Instruction::LocalGet(found))
2156+
.instruction(&Instruction::StructNew(
2157+
context.gc.index(Type::Option(option)),
2158+
))
2159+
.instruction(&Instruction::End);
2160+
}
21322161
IntrinsicId::StringToAsciiLowerCase => {
21332162
compile_receiver(function, target, context);
21342163
function.instruction(&Instruction::Call(

src/intrinsic_registry.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ const fn synchronous_scratch(id: IntrinsicId) -> Option<ScratchPolicy> {
302302
IntrinsicId::TimerState => scratch(ScratchType::Core(CoreTypeId::U32), 1),
303303
IntrinsicId::TimerCurrentSplitIndex => scratch(ScratchType::Core(CoreTypeId::I64), 1),
304304
IntrinsicId::TimerSegmentWasSplit => scratch(ScratchType::Core(CoreTypeId::I32), 1),
305+
IntrinsicId::StringIndexOf => scratch(ScratchType::Core(CoreTypeId::I32), 1),
305306
IntrinsicId::ProcessFollow
306307
| IntrinsicId::ProcessReadRelative32
307308
| IntrinsicId::ProcessReadUtf8
@@ -388,6 +389,7 @@ const fn dependency_roots(id: IntrinsicId) -> &'static [DependencyRoot] {
388389
| IntrinsicId::StringStartsWith
389390
| IntrinsicId::StringEndsWith
390391
| IntrinsicId::StringEqualsIgnoreAsciiCase => &[Helper(Runtime::StringMatch)],
392+
IntrinsicId::StringIndexOf => &[Helper(Runtime::StringFind)],
391393
IntrinsicId::StringToAsciiLowerCase => &[Helper(Runtime::StringToAsciiLowerCase)],
392394
IntrinsicId::StringReplaceAll => &[Helper(Runtime::StringReplaceAll)],
393395
IntrinsicId::StringSplit => &[Helper(Runtime::StringSplit)],
@@ -493,6 +495,10 @@ const U64_OPTION: ContractTypeRef = ContractTypeRef::Application {
493495
constructor: StdlibTypeConstructorId::Option,
494496
arguments: &[U64],
495497
};
498+
const U32_OPTION: ContractTypeRef = ContractTypeRef::Application {
499+
constructor: StdlibTypeConstructorId::Option,
500+
arguments: &[U32],
501+
};
496502
const BOOL_OPTION: ContractTypeRef = ContractTypeRef::Application {
497503
constructor: StdlibTypeConstructorId::Option,
498504
arguments: &[BOOL],
@@ -1097,6 +1103,19 @@ pub(crate) const fn contract(id: IntrinsicId) -> IntrinsicContract {
10971103
Everywhere,
10981104
RepresentationPrimitive
10991105
),
1106+
IntrinsicId::StringIndexOf => contract!(
1107+
StringIndexOf,
1108+
Method,
1109+
signature(
1110+
NO_TYPE_PARAMETERS,
1111+
Some(STRING),
1112+
params![value(STRING)],
1113+
U32_OPTION,
1114+
),
1115+
PURE,
1116+
Everywhere,
1117+
RepresentationPrimitive
1118+
),
11001119
IntrinsicId::StringStartsWith => contract!(
11011120
StringStartsWith,
11021121
Method,

src/stdlib/catalog.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ const fn validation_fixture(item: StdlibItemId) -> &'static str {
204204
| StdlibItemId::TimerIsRunning
205205
| StdlibItemId::StringByteLength
206206
| StdlibItemId::StringContains
207+
| StdlibItemId::StringIndexOf
207208
| StdlibItemId::StringStartsWith
208209
| StdlibItemId::StringEndsWith
209210
| StdlibItemId::StringEqualsIgnoreAsciiCase

src/stdlib/intrinsics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ macro_rules! trusted_intrinsics {
6161
RuntimeArchitecture,
6262
StringLength,
6363
StringContains,
64+
StringIndexOf,
6465
StringStartsWith,
6566
StringEndsWith,
6667
StringEqualsIgnoreAsciiCase,

stdlib/standard.split

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,24 @@ intrinsic type String {
15511551
substring: String,
15521552
) -> bool;
15531553

1554+
/// Finds the first exact substring and returns its UTF-8 byte offset.
1555+
///
1556+
/// Returns `None` when the substring is absent. An empty substring is found
1557+
/// at byte offset zero. Searching is allocation-free and case-sensitive.
1558+
///
1559+
/// # Example
1560+
///
1561+
/// Find a separator in an ASCII scene identifier
1562+
///
1563+
/// ```splitscript
1564+
/// let separator = sceneName.indexOf("_") else return
1565+
/// ```
1566+
@intrinsic(StringIndexOf)
1567+
fn indexOf(
1568+
/// The substring to search for.
1569+
substring: String,
1570+
) -> u32?;
1571+
15541572
/// Tests whether this string starts with an exact prefix.
15551573
///
15561574
/// Matching is case-sensitive over the UTF-8 text. Every string starts

0 commit comments

Comments
 (0)