1. Normalization before parsing
When we read a string via readln(), it feels like we got a “command” or “data”. In reality, we got raw text: with spaces, inconsistent casing, random commas, extra separators, and sometimes input like “I pressed space five times because I can”. Normalization is the step where we bring input to a clear standard so that parsing is predictable.
A good strategy here is like in the previous lecture where we did “read → prepare → parse”. Now it’s just that “preparation” became more substantial:
flowchart TD
A["readln(): raw input"] --> B["trim(): clean edges"]
B --> C["normalize spaces/separators"]
C --> D["normalize case (if needed)"]
D --> E["split(): split into parts"]
E --> F["check size and part types"]
Note: we don’t make the string perfect; we do exactly as much as needed so that the logic works reliably. And yes, it’s normal if normalization takes more lines of code than the actual useful work: a program is not only about “doing it”, but also about “doing it right”.
Normalizing case: lowercase() and uppercase()
The next typical problem is casing. One user will enter Add, another ADD, a third add, and a fourth will write aDd (and feel proud). If you compare strings directly, all these variants are different. So when we want to recognize a “command” or an “action type”, it’s convenient to bring the input to a single case.
It’s important to distinguish: command and data are not the same thing. A command is usually normalized (for example, to lowercase), while a “product name” or a “comment” is sometimes better left as-is so you don’t turn iPhone into iphone (though sometimes that’s acceptable—it depends on the task).
Example: normalize the command, but keep the argument as-is:
fun main() {
val rawLine = " AdD Milk "
val line = rawLine.trim()
val parts = line.split(" ")
val command = parts[0].lowercase()
val item = if (parts.size >= 2) parts[1] else ""
println("command='$command', item='$item'") // command='add', item='Milk'
}
Here we already used split(), but so far without normalizing spaces—and that will come back to bite us. For now, just lock in the idea: lowercase() and uppercase() are a way to make comparisons robust.
2. Removing edge spaces: trim*
Almost any user input starts and ends with spaces more often than we’d like. It’s not because people are bad—keyboards are just built that way, and everyone has different habits. So the first thing we usually do to a string before parsing is remove spaces at the edges. Kotlin provides trim(), and if you need more control—trimStart() and trimEnd().
- trim() — removes spaces from both ends of the string
- trimStart() — removes spaces from the beginning of the string
- trimEnd() — removes spaces from the end of the string
It’s important to remember one thing: strings in Kotlin behave as “immutable” (meaning: methods return a new string). So raw.trim() does not change raw; it returns a new string. This is a common reason for code “not working”.
Example—let’s see the difference between the three methods:
fun main() {
val raw = " hello "
println(">" + raw.trim() + "<") // >hello<
println(">" + raw.trimStart() + "<") // >hello <
println(">" + raw.trimEnd() + "<") // > hello<
}
Here you can notice a useful trick: I intentionally added > and < to visually see the spaces. It’s a small “debugging by eye” technique that saves a lot of time.
If you expect input like “command + arguments”, then trim() should almost always be the very first step. Otherwise, strings suddenly stop matching when compared: "add" and " add" are different strings.
3. Splitting a string into parts: split()
When the string becomes “clean” (at least more or less), we can split it into parts. For that, there is split(...). In the basic scenario, you will often see split(" ")—“split by spaces”.
The result of split() is a list of parts (with type List<String>). We are not going deep into collections yet (that will be much later), but two facts are enough for us: the result has size, and elements can be accessed by index parts[i]. Lists are zero-indexed: the first element is index 0, the last is size - 1.
Why you can’t immediately do parts[1]
It’s very tempting to write:
val parts = line.split(" ")
val second = parts[1] // "it definitely exists!"
But the user is unpredictable. They may enter only one word. Then parts[1] is out of bounds, and the program crashes.
The correct style at our current level is to check size before accessing:
fun main() {
val line = "add"
val parts = line.split(" ")
val cmd = parts[0]
val arg = if (parts.size >= 2) parts[1] else "(no argument)"
println("cmd='$cmd', arg='$arg'") // cmd='add', arg='(no argument)'
}
It looks a bit longer, but it doesn’t break for no reason. And “doesn’t break for no reason” is one of the best compliments for code.
4. Targeted replacements: replace() and replaceFirst()
After we remove edge spaces and fix casing, there is still a lot of “small junk” left: extra commas, double spaces, random dots, or the user put , instead of a space (because “that’s what I’m used to”). This is where replace(old, new) comes in.
replace(old, new) replaces all occurrences of old with new. And replaceFirst(old, new) replaces only the first occurrence. This matters because sometimes you want to “fix the format” once, and sometimes you really want to replace everything.
A mini-example to feel the difference:
fun main() {
val s = "one-two-two"
println(s.replace("two", "X")) // one-X-X
println(s.replaceFirst("two", "X")) // one-X-two
}
Normalizing multiple spaces without regex
A typical programmer pain point: the user enters multiple spaces in a row, and split(" ") starts behaving “not the way you expected”. The simplest strategy without regular expressions (we won’t touch them today) is to “compress” repeated spaces with a loop:
fun normalizeSpaces(text: String): String {
var s = text.trim()
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " milk 2 30 "
println(normalizeSpaces(raw)) // milk 2 30
}
Yes, it’s not the fastest method on the planet. But for learning examples and for typical “one-line user input”, it’s more than enough. And most importantly, it’s understandable. When code is understandable, it’s easier to fix than “magical optimization”.
5. Parsing a purchase
Now let’s connect everything into one small scenario and continue developing our learning console app. Let it be able (for now) to parse one line—a “purchase record”—in the format:
<name> <qty> <price>
For example:
milk 2 30
We want the app to be patient and accept input like:
Milk, 2 30
Meaning: extra spaces, a comma after the name, different casing.
Step 1: normalize “as agreed”
Let’s make a function that brings the string to a more stable form:
fun normalizePurchaseLine(raw: String): String {
var s = raw.trim()
// Allow the user to type commas: replace them with spaces
s = s.replace(",", " ")
// Compress multiple spaces to a single one
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " Milk, 2 30 "
println(normalizePurchaseLine(raw)) // Milk 2 30
}
Note: we intentionally do not touch casing here. We keep the item name exactly as the user entered it. If later we need to compare names (for example, treat milk and Milk as the same), then we will separately decide what exactly (and where) to convert to lowercase().
Step 2: split into parts and validate the shape
Now let’s write a function that takes a normalized string, splits it into parts, and checks that there are exactly three parts:
fun splitPurchase(line: String): List<String> {
val normalized = normalizePurchaseLine(line)
return normalized.split(" ")
}
fun main() {
val parts = splitPurchase(" Milk, 2 30 ")
println("parts.size=${parts.size}") // parts.size=3
println("parts[0]='${parts[0]}'") // parts[0]='Milk'
}
We use the fact that the result of split() is a list that has size and indexed access.
Step 3: convert parts to numbers and form the response
Quantity and price are numbers. We already have experience with toIntOrNull() and checking for null. Let’s keep it simple: if the number doesn’t parse, we honestly return an error message.
Let’s make a function that returns a ready-made message (success or error). This is a convenient solution at the current level: one result string, and main just prints it.
fun parsePurchaseToMessage(line: String): String {
val parts = splitPurchase(line)
if (parts.size != 3) {
return "Error: expected format '<item> <qty> <price>'"
}
val item = parts[0]
val qty = parts[1].toIntOrNull()
val price = parts[2].toIntOrNull()
if (qty == null || price == null) {
return "Error: qty and price must be integers"
}
val total = qty * price
return "Purchase: $item, qty=$qty, price=$price, total=$total"
}
fun main() {
println(parsePurchaseToMessage(" Milk, 2 30 "))
// Purchase: Milk, qty=2, price=30, total=60
}
Notice how much calmer life became: main() no longer deals with trivialities. It doesn’t “chop the string” and doesn’t catch shape errors. It delegates that to a function and simply prints the result.
Step 4: normalize case where it is needed
A common question: “Can we make MILK, Milk, and milk be considered the same?”. Yes. But it’s better to do it consciously: we normalize not “everything”, but only what we plan to compare.
For example, we can create a “canonical item name” for internal computations (for now, just show the idea in the output):
fun main() {
val item = "Milk"
val canonical = item.lowercase()
println("item='$item', canonical='$canonical'") // item='Milk', canonical='milk'
}
This idea will later be very useful for commands, categories, identifiers—anything that should match regardless of input. Just remember: you don’t always want to lowercase user text. Sometimes it ruins the “meaningful appearance” of the data.
6. Common mistakes
Mistake #1: call trim() and forget to store the result.
A very common scenario: you wrote line.trim() and then kept working with line. But trim() returns a new string, while the original remains unchanged. So either write val cleaned = line.trim(), or (if you need to reassign) var line = line.trim().
Mistake #2: expect trim() to remove spaces inside the string.
trim() works only on the edges. If you have "milk 2 30", then after trim() the inner spaces will remain. For inner spaces you need separate logic (for example, the loop while (s.contains(" ")) s = s.replace(" ", " ")), like we did above.
Mistake #3: use split(" ") on a “dirty” string and be surprised by empty parts.
If the string contains many spaces in a row, splitting by a single space can produce unexpected results (up to “empty” elements), and your parser starts getting confused. The simplest way out at the current level is: first normalize spaces, then do split().
Mistake #4: take parts[1] and parts[2] without checking size.
Even if “according to the assignment” the user must enter three parts, in real input they might enter one or two. Without checking size, you get a crash due to out-of-bounds access. Lists are zero-indexed, and the last index is size - 1.
Mistake #5: compare commands without normalizing case.
If somewhere you check for "add", and the user enters "Add", the condition won’t match. So commands should almost always be converted to lowercase() (or uppercase()), and then compared as normalized strings.
Mistake #6: confuse replace and replaceFirst.
Sometimes you need to replace only the first occurrence (for example, fix “one separator”), and sometimes you need all occurrences. If you use replace() thinking it replaces only once, you can silently damage the data: the string changes “too much”, and the bug won’t be obvious.
Mistake #7: mix “normalization” and “parsing” into one unreadable blob.
Technically, you can write a monster like line.trim().lowercase().replace(...).split(...)..., but reading and debugging it is painful. It’s much calmer to do: a separate normalization function, then a separate split() step, then checks and parsing. This kind of code is easier to maintain and easier to extend.
GO TO FULL VERSION