JavaRush /Courses /Kotlin SELF /Safe string parsing — indexOf, substring, split, and prot...

Safe string parsing — indexOf, substring, split, and protecting against boundary errors

Kotlin SELF
Level 27, Lesson 0
Available

1. Introduction

When you start parsing a string “into parts,” you suddenly discover that a string is not a friendly little bag of cookies, but rather a box of cookies that sometimes has no lid. A user can enter an empty string, forget a delimiter, type two spaces in a row, or write something “creative” altogether. In this lecture, we learn to write code so that it does not crash, but instead predictably chooses a behaviour strategy.

Imagine your program is a cashier and the user is a customer who sometimes speaks unclearly. The cashier must not faint from the phrase “uhh… well… bread… maybe.” They should ask again or choose a default decision. String parsing is the same: we design in advance what to do if we “didn’t find it,” “didn’t fit,” or it’s “empty.”

2. Indexes and boundaries in a string

A string in Kotlin is a sequence of characters that have indexes. Indexing starts from zero. This sounds familiar, but it’s exactly where classic mistakes are born: “why can’t I take a character at index length?” and “why is lastIndex not length?”

It’s important to get used to the idea that length is the number of characters, and the last valid index is length - 1 (if the string is not empty).

To avoid keeping this in your head like a spell, it’s convenient to think of it this way: length is the “position after the last character.” It’s useful for operations like “cut to the end,” but it’s not suitable as a character index.

Here is a small diagram that is worth visualizing when you slice a string:

String:  "Kotlin"
Indexes:  012345
length = 6
lastIndex = 5

A mini-check in code (so your brain believes your eyes):

fun main() {
    val s = "Kotlin"
    println(s.length)          // 6
    println(s.lastIndex)       // 5
    println(s[0])              // K
    println(s[s.lastIndex])    // n
}

If the string is empty (""), then length == 0, and lastIndex == -1. This is not a bug, but an honest warning: “there is no last element.”

3. indexOf(...): -1 is the “not found” answer

When we search for a delimiter in a string (for example, '=', '@', ':'), the first thing we often use is indexOf. And here it’s important to accept the philosophy of Kotlin (and most languages in general): if the element is not found, you are returned -1. The program does not crash. It’s just a way to say: “I searched, but I didn’t find it.”

That’s why a typical beginner mistake sounds like this: “I found the index, immediately did substring, and everything crashed.” It crashed because the index was -1, and substring(-1, …) is already an attempt to cut text from a non-existent position.

A simple example with email:

fun main() {
    val email = "alice@example.com"
    val at = email.indexOf('@')

    val domain = if (at >= 0) email.substring(at + 1) else "INVALID"
    println(domain) // example.com
}

The point is not email (email lives its own life), but the pattern: indexOf → check → only then slice.

4. substring(...): boundaries and “end is not included”

substring is a powerful knife. And like any knife, it works well as long as you don’t wave it around at random.

The most important rule:

  • substring(start, end) takes characters from index start inclusive up to index end exclusive.

That is, end is the “stop position,” not the “last character.”

Let’s reinforce this with a short example:

fun main() {
    val s = "Kotlin"
    println(s.substring(0, 1)) // K
    println(s.substring(0, 2)) // Ko
    println(s.substring(1, 4)) // otl
}

Why is this convenient? Because you can safely set end equal to length. That’s the “position after the last character,” and it’s a valid cut boundary.

So this is fine:

fun main() {
    val s = "Kotlin"
    println(s.substring(0, s.length)) // Kotlin
}

But this is already a boundary error:

// s.substring(0, s.length + 1)  // you must not do this: you will go out of bounds

substring(start) as a way to take the “tail”

Sometimes you don’t need to specify end, because “to the end of the string” is exactly what you want. For that, there is the substring(start) form.

It’s convenient to use when you have already found a delimiter and want to take everything after it:

fun main() {
    val path = "/users/alice"
    val slash = path.indexOf('/')

    val tail = if (slash >= 0) path.substring(slash + 1) else path
    println(tail) // users/alice
}

Yes, the example is intentionally “weird” (because '/' is at the beginning). But it shows the idea: substring(start) removes the need to think about length and “end is not included.” You simply say: “give me everything from here to the end.”

5. Pattern: find delimiter, check, slice

Now we’ll assemble the main working pattern of the lecture. You need it everywhere: configs like key=value, commands like add:100:food, log lines, “name: value,” basically anything.

Algorithm in plain language:

  1. Find the delimiter (indexOf).
  2. If it’s not found, choose a behaviour (return null, return a default, treat the whole string as the “key,” etc.).
  3. If it’s found, carefully slice the left and right parts.

Here is a “key=value” example:

fun main() {
    val line = "key=value"
    val eq = line.indexOf('=')

    val key = if (eq >= 0) line.substring(0, eq) else line
    val value = if (eq >= 0) line.substring(eq + 1) else ""

    println("key=$key, value=$value") // key=key, value=value
}

Notice: with one found index (eq) we extract both parts. This is not only shorter, but also safer: you won’t end up slicing the “key” by one rule and the “value” by another.

6. Preparing input: trim() and safe split

User input is almost always “dirty”: extra spaces, tabs, random line breaks, a trailing space, a leading space. And if you start parsing the string without preparation, you will get weird errors like “everything seems correct, but why is the key empty?”

So the basic discipline looks like this: first trim(), then analyse.

fun main() {
    val raw = "   key=value   "
    val line = raw.trim()

    val eq = line.indexOf('=')
    val key = if (eq >= 0) line.substring(0, eq) else line
    val value = if (eq >= 0) line.substring(eq + 1) else ""

    println("key='$key', value='$value'") // key='key', value='value'
}

Here the quotes in the output are purely a teaching trick: they help you visually see spaces if they somehow remain.

split(" ") and multiple spaces

When we want to split a string into “words,” most often we do split(" "). And with neat input it works. But with input like "one two three" you suddenly get empty strings among the results. Because split(" ") literally cuts on single spaces, and if there are several spaces in a row, between them there is an “empty word.”

Therefore, in practice, when parsing commands and input it is useful to do three steps: trim() → split(" ") → filter { it.isNotEmpty() }.

fun main() {
    val raw = "   one  two   three  "
    val parts = raw.trim()
        .split(" ")
        .filter { it.isNotEmpty() }

    println(parts) // [one, two, three]
}

You will see this pattern constantly, because it turns “human input” into “computer tokens.” In a similar style, functions for reading structured input are often written.

7. Strategies on error: marker vs null

Now an important design point: what should you do if the string “does not fit”?

There are two very common approaches.

  • First approach — return a marker (for example, "INVALID" or an empty string) and continue. It can be convenient if you are generating a report and want to “show at least something.” But it is dangerous because the marker is easy to forget to handle, and then "INVALID" unexpectedly travels further as if it were a normal value.
  • Second approach — return null and force the calling code to make a decision. This style is usually more reliable: you explicitly see that the result may be missing. We already know how to work with nullable types, so today this will be our main tool.

Let’s add a small parser function to our training console app (a hypothetical expense tracker): “parse a key=value token.”

Function parseKeyValue(token: String): Pair<String, String>?

This function prints nothing and reads nothing; it just carefully parses a string and returns either a pair or null. This style is easy to test and reuse.

fun parseKeyValue(token: String): Pair<String, String>? {
    val eq = token.indexOf('=')
    if (eq < 0) return null

    val key = token.substring(0, eq).trim()
    val value = token.substring(eq + 1).trim()

    if (key.isEmpty()) return null
    return key to value
}

Notice the “safety points”:

  • eq < 0 → immediately null, we do not try to slice.
  • substring(eq + 1) is safe only because eq >= 0. When eq == token.lastIndex the value will be empty, but that’s not a crash.
  • key.isEmpty() — protection from cases like "=123".

Mini-check:

fun main() {
    println(parseKeyValue("amount=120")) // (amount, 120)
    println(parseKeyValue("=120"))       // null
    println(parseKeyValue("amount="))    // (amount, )
    println(parseKeyValue("amount"))     // null
}

Parsing a command: “read → prepare → parse”

Now let’s assemble a small (but realistic) piece of a console interface for our expense tracking application.

We want the user to enter commands in a style like:

  • add amount=120 category=food note=coffee
  • add amount=999 category=tech note=keyboard

We will not complicate formatting, storage, reports, etc. today. Our goal is not to crash on messy input and to clearly understand why the command did not parse.

First, clean the input and split into tokens:

fun splitTokens(line: String): List<String> {
    return line.trim()
        .split(" ")
        .filter { it.isNotEmpty() }
}

Check:

fun main() {
    val tokens = splitTokens("   add   amount=120   category=food   ")
    println(tokens) // [add, amount=120, category=food]
}

Next, safely obtain the command and arguments. Important: tokens[0] is dangerous if the list is empty. Therefore, we first check isEmpty().

fun main() {
    val line = readln().trim()
    val tokens = splitTokens(line)

    if (tokens.isEmpty()) {
        println("Empty command. Enter something.")
        return
    }

    val command = tokens[0].lowercase()
    val args = tokens.drop(1)
    println("command=$command, args=$args")
}

We use readln() as the main input method in console programs.

Parsing key=value arguments into a Map

Now we’ll take the next step: process the list of tokens after the add command and try to extract amount, category, note.

Let’s make a function that iterates over tokens and builds a Map<String, String> from valid key=value tokens. We already know Map and understand that map[key] can return null, so this also fits our safe-programming style.

fun parseArgs(tokens: List<String>): Map<String, String> {
    val result = mutableMapOf<String, String>()

    for (t in tokens) {
        val kv = parseKeyValue(t) ?: continue
        val (k, v) = kv
        result[k] = v
    }

    return result
}

Check:

fun main() {
    val args = parseArgs(listOf("amount=120", "category=food", "oops", "note=coffee"))
    println(args) // {amount=120, category=food, note=coffee}
}

Notice that we chose a strategy: if a token is “bad” ("oops"), we skip it. We could have returned an error, but that’s a different strategy. Today, we care about resilience and simplicity.

Final mini-scenario: the add command and diagnostic messages

Now let’s put everything together: read a line, extract the command, parse arguments, check that required fields are present.

We are not going to implement full expense storage right now (that already was/will be in other lectures). We will just show that parsing works and does not crash.

fun main() {
    val tokens = splitTokens(readln())

    if (tokens.isEmpty()) {
        println("Enter a command, for example: add amount=120 category=food note=coffee")
        return
    }

    val command = tokens[0].lowercase()
    val args = parseArgs(tokens.drop(1))

    if (command != "add") {
        println("Unknown command: $command")
        return
    }

    val amountRaw = args["amount"]
    val category = args["category"]
    val note = args["note"] ?: ""

    val amount = amountRaw?.toIntOrNull()
    if (amount == null || category == null || category.isBlank()) {
        println("Invalid input. Required: amount=<Int> and category=<text>")
        return
    }

    println("Adding expense: amount=$amount, category=$category, note=$note")
}

There are several important “safety-minded” things here:

  • We do not assume the command exists.
  • We do not assume amount and category are provided.
  • We do not assume amount is a number (we use toIntOrNull()).
  • We do not slice strings by indexes without checks, because we moved that into parseKeyValue.

8. Typical mistakes when parsing strings

Mistake #1: using the result of indexOf as if it is always valid.
Most often it looks like this: val i = s.indexOf('='); val left = s.substring(0, i). On “correct” strings everything works, but on a string without '=' you get an exception. It’s fixed by one habit: always check indexOf, because -1 is the normal “not found” response.

Mistake #2: confusing substring(start, end) boundaries and expecting end to be inclusive.
This is a classic because the brain likes “inclusive.” In Kotlin, end is not included. If you slice “up to the delimiter,” then substring(0, eq) already excludes the '=' character itself, and that’s exactly convenient. But trying to “include the last character” often leads to end = length + 1 and a crash.

Mistake #3: slicing an empty string and hoping there is at least one character.
This happens in code like substring(0, 1) without checks. With empty input, it’s a failure. In resilient code, you either check isNotEmpty(), or design the logic so that an empty string is handled as a separate branch (for example, “ask to re-enter input”).

Mistake #4: doing split(" ") and believing “extra spaces won’t hurt.”
They do. Multiple spaces produce empty tokens, which then unexpectedly break argument parsing. The pattern “trim() → split(" ") → filter { it.isNotEmpty() }” makes the behaviour predictable and eliminates strange bugs that look like “sometimes it works, sometimes it doesn’t.”

Mistake #5: trying to “slice at any cost” instead of choosing an error strategy.
Sometimes you want to write: “well, the user should enter it correctly.” But practice shows the opposite: the user enters whatever they manage. Therefore, you need to decide in advance: on error, do we return null, return a default, or print a diagnostic message and ask to try again. The main thing is that the behaviour is explicit, not “it somehow worked out.”

1
Task
Kotlin SELF, level 27, lesson 0
Locked
Support mailbox
Support mailbox
1
Task
Kotlin SELF, level 27, lesson 0
Locked
Setting a parameter
Setting a parameter
1
Task
Kotlin SELF, level 27, lesson 0
Locked
Shift Timer
Shift Timer
1
Task
Kotlin SELF, level 27, lesson 0
Locked
Command parsing
Command parsing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION