Skip to content

Commit 09cb3cb

Browse files
committed
Add typed string emptiness
1 parent 3e117b7 commit 09cb3cb

11 files changed

Lines changed: 129 additions & 1 deletion

File tree

‎crates/splitscript-syntax/src/migration.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ pub const CSHARP_STRING_REPLACE_DIAGNOSTIC: MigrationDiagnosticId =
187187
MigrationDiagnosticId::new("csharp.string.replace-call");
188188
pub const CSHARP_STRING_TRIM_DIAGNOSTIC: MigrationDiagnosticId =
189189
MigrationDiagnosticId::new("csharp.string.trim-call");
190+
pub const CSHARP_STRING_IS_NULL_OR_EMPTY_DIAGNOSTIC: MigrationDiagnosticId =
191+
MigrationDiagnosticId::new("csharp.string.is-null-or-empty-call");
190192
pub const CSHARP_NUMERIC_PARSE_DIAGNOSTIC: MigrationDiagnosticId =
191193
MigrationDiagnosticId::new("csharp.numeric.static-parse-call");
192194
pub const CSHARP_TIMESPAN_PARSE_DIAGNOSTIC: MigrationDiagnosticId =
@@ -406,6 +408,18 @@ pub const DIAGNOSTICS: &[MigrationDiagnostic] = &[
406408
"there is no automatic rewrite because the compiler cannot prove that the input's surrounding whitespace is ASCII",
407409
],
408410
},
411+
MigrationDiagnostic {
412+
id: CSHARP_STRING_IS_NULL_OR_EMPTY_DIAGNOSTIC,
413+
concept: MigrationConceptId::new("string.null-or-empty"),
414+
message: "C# `String.IsNullOrEmpty` crosses SplitScript's Option boundary",
415+
primary_label: "choose emptiness or optional absence from the value's type",
416+
notes: &[
417+
"for a required `String`, rewrite `String.IsNullOrEmpty(value)` as `value.isEmpty()` because the value cannot be null",
418+
"for `String?`, use `match value { None => true, Some(text) => text.isEmpty() }` so absence remains explicit",
419+
"process and state read failures are not automatically null strings; preserve their declared Result or Option policy before checking emptiness",
420+
"there is no automatic rewrite because the static call does not reveal whether the migrated value should be required, optional, or fallible",
421+
],
422+
},
409423
MigrationDiagnostic {
410424
id: CSHARP_NUMERIC_PARSE_DIAGNOSTIC,
411425
concept: MigrationConceptId::new("string.numeric-parse"),
@@ -459,6 +473,9 @@ pub fn legacy_static_call_diagnostic(path: &[String]) -> Option<MigrationDiagnos
459473
let [owner, method] = path else {
460474
return None;
461475
};
476+
if owner == "String" && method == "IsNullOrEmpty" {
477+
return Some(CSHARP_STRING_IS_NULL_OR_EMPTY_DIAGNOSTIC);
478+
}
462479
if owner == "Duration" && method == "Parse" {
463480
return Some(CSHARP_TIMESPAN_PARSE_DIAGNOSTIC);
464481
}
@@ -874,6 +891,16 @@ pub const CONCEPTS: &[MigrationConcept] = &[
874891
cookbook_anchor: Some("c-string-operations"),
875892
spellings: &[],
876893
},
894+
MigrationConcept {
895+
id: MigrationConceptId::new("string.null-or-empty"),
896+
name: "Nullable string emptiness",
897+
sources: CSHARP,
898+
support: MigrationSupport::TypedPattern,
899+
summary: "Use `String.isEmpty` for required strings and match `String?` explicitly when absence should also count as empty.",
900+
targets: &[MigrationTarget::StandardLibraryItem("String.isEmpty")],
901+
cookbook_anchor: Some("c-string-operations"),
902+
spellings: &[],
903+
},
877904
MigrationConcept {
878905
id: MigrationConceptId::new("string.numeric-parse"),
879906
name: "Numeric string parsing",

‎docs/ASL_PORTING.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,28 @@ immutable string when nothing changes. The compiler does not rewrite `Trim()`
128128
automatically because Unicode whitespace, character-array overloads,
129129
`TrimStart`, and `TrimEnd` have different semantics.
130130

131+
C# combines nullability and emptiness in `String.IsNullOrEmpty(value)`.
132+
SplitScript keeps those concerns in the type. A required `String` cannot be
133+
null, so use its source-defined method directly:
134+
135+
```splitscript
136+
let missingCheckpoint = current.checkpoint.isEmpty()
137+
```
138+
139+
When the migrated value is deliberately optional, handle both variants:
140+
141+
```splitscript
142+
let missingCheckpoint = match current.checkpoint {
143+
None => true,
144+
Some(text) => text.isEmpty(),
145+
}
146+
```
147+
148+
A failed process read is not automatically an empty or null string. Decide
149+
first whether that boundary should remain a `Result`, become a `String?`, or
150+
retain the last accepted state value. The compiler therefore gives
151+
`String.IsNullOrEmpty` focused guidance without guessing an automatic rewrite.
152+
131153
C# `value.IndexOf(substring)` returns a UTF-16 code-unit index or `-1`.
132154
SplitScript `value.indexOf(substring)` instead returns a UTF-8 byte offset as
133155
`u32?`; handle `None` directly. The numeric offsets are equivalent only for

‎docs/LANGUAGE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,6 +1424,7 @@ is involved:
14241424
| Operation | Behavior |
14251425
| --- | --- |
14261426
| `byteLength()` | UTF-8 byte length |
1427+
| `isEmpty()` | Whether the required string contains zero UTF-8 bytes |
14271428
| `contains(text)` | Case-sensitive substring test |
14281429
| `indexOf(text)` | First matching UTF-8 byte offset as `u32?` |
14291430
| `startsWith(text)` / `endsWith(text)` | Case-sensitive prefix/suffix tests |

‎docs/MIGRATION_CAPABILITIES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This index maps common source-language concepts to canonical SplitScript APIs an
1717
| `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). |
1818
| `string.replacement` — Exact string replacement | C# | Use a typed pattern | Use fallible `replaceAll` for immutable exact replacement; explicitly handle failure and translate a null C# replacement to an empty string only when deletion was intended. Canonical targets: `String.replaceAll`. [Recipe](ASL_PORTING.md#c-string-operations). |
1919
| `string.ascii-trim` — ASCII whitespace trimming | C# | Use a typed pattern | Use `trimAsciiWhitespace` for text known to use ASCII boundary whitespace; review Unicode and character-set trimming explicitly. Canonical targets: `String.trimAsciiWhitespace`. [Recipe](ASL_PORTING.md#c-string-operations). |
20+
| `string.null-or-empty` — Nullable string emptiness | C# | Use a typed pattern | Use `String.isEmpty` for required strings and match `String?` explicitly when absence should also count as empty. Canonical targets: `String.isEmpty`. [Recipe](ASL_PORTING.md#c-string-operations). |
2021
| `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). |
2122
| `type.duration` — Timer durations | C# | Supported directly | Use `Duration` instead of C#'s `TimeSpan`. Canonical targets: `Duration`. |
2223
| `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: typed string emptiness
4+
5+
- Added source-defined `String.isEmpty()` as ordinary composition over
6+
`byteLength`, with generated documentation, completion, hover, must-use, and
7+
runtime coverage but no backend intrinsic.
8+
- Added focused guidance for the recurring C# `String.IsNullOrEmpty` pattern.
9+
Required strings use `.isEmpty()`, while `String?` must explicitly match
10+
`None` and `Some`; failed process reads retain their separate Result policy.
11+
- Deliberately avoided recreating nullable strings or guessing an automatic
12+
rewrite from a static C# call whose migrated value type is not yet known.
13+
314
## 2026-08-09: explicit ASCII whitespace trimming
415

516
- Added `String.trimAsciiWhitespace()` for recurring corpus parsing of game

‎src/completion.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,6 +1719,7 @@ split {
17191719
let literal = labels(&mut database, "2 => \"½\".");
17201720
for member in [
17211721
"byteLength",
1722+
"isEmpty",
17221723
"contains",
17231724
"startsWith",
17241725
"endsWith",
@@ -1739,6 +1740,7 @@ split {
17391740
let local = labels(&mut database, "return fraction.");
17401741
for member in [
17411742
"byteLength",
1743+
"isEmpty",
17421744
"contains",
17431745
"startsWith",
17441746
"endsWith",

‎stdlib/standard.split‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,6 +1533,23 @@ intrinsic type String {
15331533
@intrinsic(StringLength)
15341534
fn byteLength() -> u32;
15351535

1536+
/// Reports whether this string contains no UTF-8 bytes.
1537+
///
1538+
/// `String` itself is never absent. Use ordinary `match` on `String?` when
1539+
/// absence and emptiness should both satisfy a condition.
1540+
///
1541+
/// # Example
1542+
///
1543+
/// Ignore an empty checkpoint name
1544+
///
1545+
/// ```splitscript
1546+
/// let hasCheckpoint = !checkpointName.isEmpty()
1547+
/// ```
1548+
@mustUse("The string emptiness result must be used.")
1549+
fn isEmpty() -> bool {
1550+
return self.byteLength() == 0
1551+
}
1552+
15361553
/// Tests whether this string contains an exact substring.
15371554
///
15381555
/// Matching is case-sensitive over the UTF-8 text. An empty substring is

‎tests/compiler/catalogs_types.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,10 @@ fn standard_library_catalog_is_valid_documented_and_compilable() {
964964
library.render_signature(StdlibItemId::StringToAsciiLowerCase),
965965
"String.toAsciiLowerCase() -> String"
966966
);
967+
assert_eq!(
968+
library.render_signature(StdlibItemId::StringIsEmpty),
969+
"String.isEmpty() -> bool"
970+
);
967971
assert_eq!(
968972
library.render_signature(StdlibItemId::StringToAsciiUpperCase),
969973
"String.toAsciiUpperCase() -> String"

‎tests/compiler/diagnostics_migration.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,46 @@ fn csharp_string_trim_explains_ascii_whitespace_boundaries() {
243243
}));
244244
}
245245

246+
#[test]
247+
fn csharp_is_null_or_empty_explains_required_and_optional_strings() {
248+
let source = r#"
249+
state "game.exe" {}
250+
251+
fn missingCheckpoint(value: String?) -> bool {
252+
return String.IsNullOrEmpty(value)
253+
}
254+
"#;
255+
let diagnostics = splitscript::compile(source)
256+
.expect_err("C# IsNullOrEmpty needs Option-aware migration guidance");
257+
258+
assert_eq!(diagnostics.len(), 1, "unexpected cascade: {diagnostics:#?}");
259+
let diagnostic = &diagnostics[0];
260+
assert_eq!(
261+
diagnostic.message,
262+
"C# `String.IsNullOrEmpty` crosses SplitScript's Option boundary"
263+
);
264+
assert_eq!(
265+
&source[diagnostic.span.start..diagnostic.span.end],
266+
"IsNullOrEmpty"
267+
);
268+
assert!(diagnostic.fixes.is_empty());
269+
assert!(
270+
diagnostic
271+
.notes
272+
.iter()
273+
.any(|note| { note.contains("required `String`") && note.contains("value.isEmpty()") })
274+
);
275+
assert!(diagnostic.notes.iter().any(|note| {
276+
note.contains("String?") && note.contains("None") && note.contains("Some(text)")
277+
}));
278+
assert!(
279+
diagnostic
280+
.notes
281+
.iter()
282+
.any(|note| note.contains("Result or Option policy"))
283+
);
284+
}
285+
246286
#[test]
247287
fn csharp_static_numeric_parse_explains_result_based_string_parsing() {
248288
for call in ["Int32.Parse(text)", "Double.Parse(text)"] {

‎tests/string_predicates.split‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ whileAttached {
114114
{
115115
matches += 131072
116116
}
117+
if "".isEmpty() && !"Map_A".isEmpty() {
118+
matches += 262144
119+
}
117120
let levelParts = "01_02_0.dds".split(".") else []
118121
let identifiers = levelParts[0].split("_") else []
119122
let emptyParts = "_a__".split("_") else []

0 commit comments

Comments
 (0)