Chapter 3 — Numbers, Text, and Truth

Three kinds of value will carry most of your programs: numbers, strings, and booleans. Ingle has firm opinions about all three, and the opinions are mostly “be explicit, and don’t let anything happen behind your back.”

Numbers

For everyday work there are two number types:

  • int — a 64-bit signed integer. (It’s an alias for i64.)
  • float — a 64-bit floating-point number. (An alias for f64.)

Use those by default. When you genuinely care about width — packing bytes, matching a file format, squeezing memory — the full family is there: i8 i16 i32 i64, u8 u16 u32 u64, and f32 f64. Each is a distinct type.

let n = 42          // int
let big: i64 = 42   // the same thing, spelled out
let small: u8 = 200 // an 8-bit unsigned byte
let x = 200u8       // or pin the width with a suffix

Two rules will save you a great deal of confusion:

1. There is no implicit coercion. Ingle will never quietly turn an i32 into an i64, or an int into a float, to make an expression type-check. Mixing them is an error you must resolve on purpose:

let bad = 1 + 2.0   // error: you can't add an int and a float

To cross between types you convert explicitly. Integer widths convert with a type-name call — u8(x), i32(x), i64(x) — which is range-checked and traps if the value won’t fit. Between integers and floats you use to_float and to_int (the latter truncates toward zero):

let a: int = 7
let b: float = to_float(a)   // 7.0
let c: int = to_int(3.9)     // 3
let d: u8 = u8(200)          // fine; u8(300) would trap at runtime

There’s a neat split worth knowing: an out-of-range literal is caught at compile timelet x: u8 = 300 simply won’t build (“integer literal is out of range for its type”) — while an out-of-range conversion like u8(300) is a runtime trap. The compiler catches what it can see at compile time; the runtime guards the rest.

2. Overflow traps; it does not wrap. If an integer calculation overflows its width, the program stops with a clear runtime error rather than silently rolling over to a wrong answer:

fn main() -> int {
    var x = 200u8
    x = x + 100u8    // 300 doesn't fit in a u8
    return 0
}
inglec: runtime error: integer overflow

This is a deliberate safety choice. A silent wrap is one of the great sources of catastrophic, hard-to-find bugs; Ingle would rather halt and tell you. (Floating-point follows the usual IEEE-754 rules — dividing by zero gives infinity, not a trap.)

One small display quirk worth knowing now, so it doesn’t startle you later: a float that happens to be a whole number prints without a trailing .0. A distance of five prints as 5, not 5.0. It’s still a float under the hood; it just doesn’t show off about it.

Strings

A string is text in double quotes. The familiar escapes work — \n, \t, \r, \\, \" — and to write a literal brace (so it isn’t read as interpolation) you escape it: \{ and \}.

That { } is interpolation"value is {x}" splices x into the string. A hole renders a number, a string, or a bool on its own, and any value of your own type once it carries a fn show(self) -> string method (Chapter 7 shows how).

+ joins strings, and == / != compare them by content, not by identity — two strings that spell the same thing are equal:

let greeting = "Hello, " + "world"
let same = ("ab" == "a" + "b")   // true

Strings carry a handful of built-in methods. Ingle strings are UTF-8 — multi-byte Unicode characters are fully supported. Most methods operate in bytes (fast, ASCII-safe), and a separate family of cp_* functions lets you work in code points when you need Unicode-correct behaviour:

  • s.len() — the number of bytes (not code points; they differ for non-ASCII text).
  • s.chars() — an array of strings, one per code point (not per byte).
  • s.bytes() — an array of u8 values, one per byte.
  • s.char_count() — the number of Unicode code points.
  • s.split(sep) — split into an array of strings on a separator.
  • s.parse_int() — try to read the string as an integer; gives back Some(n) or None (see Chapter 9).

For Unicode-correct editing and slicing, the cp_* builtins index by code point: cp_count(s), cp_at(s, i), cp_slice(s, start, end), cp_prefix(s, n), cp_insert(s, idx, ins), cp_delete(s, idx).

fn main() -> int {
    let parts = "12,34,56".split(",")
    println("{parts.len()} parts")     // 3 parts
    match parts[0].parse_int() {
        case Some(n) { println("first is {n}") }   // first is 12
        case None    { println("not a number") }
    }
    return 0
}

There’s a fuller set of string operations — trim, to_upper, contains, replace, join, and friends — but those live in the standard library and arrive in Chapter 15. The four above are baked into the language itself.

A gotcha worth the ink. Building a big string by repeatedly doing out = out + piece in a loop is quadratic — each + copies everything so far, because strings are immutable. For a handful of pieces, who cares. For thousands, collect them in an array and join once. The standard library’s string builders do exactly this. Immutability is a lovely property right up until you fight it in a hot loop.

Truth

bool is its own type, with exactly two values, true and false. It is not a number, and this matters more than it sounds:

fn main() -> int {
    if 1 { return 1 }   // nope
    return 0
}
error: 'if' condition must be a bool

There is no “truthiness” in Ingle. 1 is not “truthy,” 0 is not “falsy,” the empty string is just a string. A condition must be an actual bool. This feels strict for about a day and then feels like a handrail for the rest of your life — a whole category of “oops, that was zero” bugs simply cannot be written.

The logical operators are && (and), || (or), and ! (not). && and || short-circuit, just as you’d expect: the right side isn’t evaluated if the left already decides the answer.

Working at the bit level

When you want to manipulate the actual bits of an integer — packing flags, masking, hashing, writing a codec — Ingle has the full set of bitwise operators: & (and), | (or), ^ (xor), ~ (not), and the shifts << and >>. These are integer-only and width-aware: they refuse floats, and they respect the type’s width, so ~ on a u8 flips eight bits, not sixty-four.

fn main() -> int {
    let a: u8 = 6          // 0000 0110
    let b: u8 = 3          // 0000 0011
    println("and  {a & b}")   // 2   — bits set in both
    println("or   {a | b}")   // 7   — bits set in either
    println("xor  {a ^ b}")   // 5   — bits set in exactly one
    println("shl  {a << 1}")  // 12  — shifted left one place
    println("shr  {a >> 1}")  // 3   — shifted right one place
    println("not  {~b}")      // 252 — every bit of a u8 flipped
    return 0
}
and  2
or   7
xor  5
shl  12
shr  3
not  252
=> 0

Two details that keep shifts honest. The shift amount must land in [0, width) — shifting a u8 by 8 or more is a runtime trap, not a quiet zero — and >> is arithmetic (sign-preserving) on signed types but logical (zero-filling) on unsigned ones, so a negative i32 >> 1 stays negative while a u32 >> 1 always pulls in zeros. The shipped examples/12_bits.ig puts all this to work twice over: a complete xorshift PRNG built from nothing but <<, >>, and ^, and a set of Unix-style permission flags packed into a single byte and toggled with masks.

A built-in safety. & is bits and && is logic, and the two never overlap — bitwise operators are never defined on bool — so the classic slip of writing & when you meant && can’t quietly compile. The overflow discipline from earlier in this chapter applies here too: shifts are range-checked, and arithmetic traps rather than wrapping.

When you actually want wrap-around. Trapping on overflow is the right default, but a whole family of primitives — non-cryptographic hashes like FNV-1a and MurmurHash, multiply-mixing PRNGs like PCG — depend on integer arithmetic wrapping round at the type’s width. So Ingle gives you exactly that, explicitly, through three builtins: wrapping_add, wrapping_sub, and wrapping_mul each take two integers of one width and compute modulo 2ʷ with no trap (there’s no wrapping / or % — overflow there isn’t a real thing). It’s the same philosophy as move: the dangerous-looking operation is available, but you have to name it, so nobody — and no model — reaches for silent wraparound by accident. With it, a real FNV-1a hash is a six-line loop in pure Ingle; without it you’d be back in C. (Shift-and-xor designs like xorshift never overflow at all, so they need none of this — examples/12_bits.ig stays library-free.)

The precedence table, once, so you never wonder

From loosest-binding to tightest, all binary operators grouping left-to-right:

Tightness Operators
loosest \|\|
  &&
  \| (bitwise or)
  ^ (bitwise xor)
  & (bitwise and)
  == !=
  < <= > >=
  << >> (shift)
  + -
tightest * / %

Prefix !, unary -, and ~ (bitwise not) bind tighter than all of those; the postfix forms (calling a function f(...), reaching into a value a.b, indexing a[i], and the ? operator) bind tightest of all. When in doubt, parenthesise — it’s free and it’s kind to the next reader.

This mirrors C exactly, which means it inherits C’s one genuinely surprising row: the bitwise operators bind looser than the comparisons, so a & b == c parses as a & (b == c), almost never what you meant. The fix is the same as in C — parenthesise mixed bitwise-and-comparison expressions — and Ingle’s strict typing tends to catch the rest, since b == c is a bool and & wants integers.

Fireside trivia. The % operator is “remainder,” and it insists on integer operands — no % on floats. The percent sign for “modulo” is one of computing’s odder inheritances: it has nothing to do with percentages. It was chosen in early languages mostly because it was a spare symbol on the keyboard that wasn’t doing anything else. Half of programming syntax is just whatever punctuation happened to be unemployed at the time.