13.3 StringBuilder — Efficient String Building

**Amortized O(n) string assembly for loops, templates, and large output generation.**

NanoLang Mascot

The modules/std/collections/stringbuilder.nano module provides a StringBuilder type and a collection of functions for building strings incrementally without the quadratic cost of repeated concatenation. Each sb_append call mutates the builder in-place and returns the same StringBuilder — the underlying C buffer grows geometrically so that the total cost of N appends is O(N) rather than O(N²).

Use StringBuilder whenever you are assembling a string in a loop, generating a document or report, or concatenating more than a handful of fragments. For simple two-or-three piece joins, plain + is fine.

---

Quick Start


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_to_string, StringBuilder

fn build_greeting(names: array<string>) -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "Hello to: ")

    let mut i: int = 0
    while (< i (array_length names)) {
        if (!= i 0) {
            set sb (sb_append sb ", ")
        }
        set sb (sb_append sb (at names i))
        set i (+ i 1)
    }

    set sb (sb_append sb "!")
    return (sb_to_string sb)
}

shadow build_greeting {
    let names: array<string> = (array_new 3 "")
    (array_set names 0 "Alice")
    (array_set names 1 "Bob")
    (array_set names 2 "Carol")
    let result: string = (build_greeting names)
    assert (== result "Hello to: Alice, Bob, Carol!")
}

---

Import


from "std/collections/stringbuilder.nano" import sb_new, sb_with_capacity,
                                                   sb_append, sb_append_line,
                                                   sb_append_int, sb_append_char,
                                                   sb_to_string, sb_length, sb_capacity,
                                                   sb_clear, sb_is_empty, sb_free,
                                                   sb_from_parts, sb_join,
                                                   sb_repeat, sb_indent,
                                                   StringBuilder

Import only the functions you need. StringBuilder is the struct type; import it to annotate variables.

---

The Performance Case

String concatenation with + is immutable and copies the entire string on every call. Building a 1,000-character string by appending one character at a time:


iteration 1:  copy 1 byte
iteration 2:  copy 2 bytes
...
iteration N:  copy N bytes
total:        N*(N+1)/2 copies  →  O(N²)

StringBuilder avoids this by maintaining an internal buffer that grows geometrically (doubling when full). Each append copies only the new fragment, and the buffer is reallocated infrequently:


N appends:    ~N total bytes copied  →  O(N) amortized

**Rule of thumb:** If you are concatenating strings in a loop — even a short one — use StringBuilder. If you are joining two or three literals or variables outside a loop, + is simpler and perfectly fast.

---

Key Concept: Mutable Handle Semantics

> **StringBuilder wraps an opaque C handle.** Unlike plain NanoLang structs, the underlying buffer is mutated in-place by every sb_append call. sb_append returns the same StringBuilder value (same handle) for chaining convenience, but the mutation has already occurred — there is no separate "before" and "after" value.

>

> This means two variables that hold the same StringBuilder (e.g. after an assignment without sb_clear) share the same underlying buffer. In practice, always use a single mut variable and set it:

>

> `nano

> let mut sb: StringBuilder = (sb_new)

> set sb (sb_append sb "Hello") # mutates buffer, returns same sb

> set sb (sb_append sb " World")

> let result: string = (sb_to_string sb) # "Hello World"

> `

>

> When you are done with a builder that you created, call sb_free to release the C buffer. Builders created by sb_from_parts, sb_join, sb_repeat, and sb_indent are freed internally — do not free them again.

---

API Reference

sb_new


fn sb_new() -> StringBuilder

Creates a new, empty StringBuilder with a default initial capacity. The capacity grows automatically as content is appended.

**Returns:** An empty StringBuilder with length = 0.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_length, StringBuilder

fn example_new() -> bool {
    let sb: StringBuilder = (sb_new)
    return (== (sb_length sb) 0)
}

shadow example_new {
    assert (example_new)
}

---

sb_with_capacity


fn sb_with_capacity(capacity: int) -> StringBuilder

Creates an empty StringBuilder pre-sized to capacity bytes. Use this when you know approximately how large the final string will be; it avoids intermediate reallocations.

**Parameters:**

NameTypeDescription
capacityintInitial buffer size in bytes.

**Example:**


from "std/collections/stringbuilder.nano" import sb_with_capacity, sb_append,
                                                   sb_to_string, sb_free, StringBuilder

fn build_large_report(line_count: int) -> string {
    # Pre-size for roughly 80 chars per line
    let estimated: int = (* line_count 80)
    let mut sb: StringBuilder = (sb_with_capacity estimated)
    let mut i: int = 0
    while (< i line_count) {
        set sb (sb_append sb (+ "Line " (+ (int_to_string i) "\n")))
        set i (+ i 1)
    }
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

---

sb_append


fn sb_append(sb: StringBuilder, text: string) -> StringBuilder

Appends text to the builder's internal buffer (mutating it in-place) and returns sb. The returned value is the same StringBuilder handle, not a new one.

**Parameters:**

NameTypeDescription
sbStringBuilderThe builder to append to
textstringText to append

**Returns:** The same StringBuilder, for convenient chaining with set.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_to_string, sb_free, StringBuilder

fn chain_append() -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "one")
    set sb (sb_append sb ", ")
    set sb (sb_append sb "two")
    set sb (sb_append sb ", ")
    set sb (sb_append sb "three")
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow chain_append {
    assert (== (chain_append) "one, two, three")
}

---

sb_append_line


fn sb_append_line(sb: StringBuilder, text: string) -> StringBuilder

Appends text followed by a newline character (\n). Equivalent to two sb_append calls but more convenient when building multi-line output.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append_line,
                                                   sb_to_string, sb_free, StringBuilder

fn build_poem() -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append_line sb "Roses are red,")
    set sb (sb_append_line sb "Violets are blue,")
    set sb (sb_append_line sb "NanoLang is fast,")
    set sb (sb_append_line sb "And memory-safe too.")
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow build_poem {
    let poem: string = (build_poem)
    assert (str_contains poem "NanoLang")
}

---

sb_append_int


fn sb_append_int(sb: StringBuilder, n: int) -> StringBuilder

Converts n to its decimal string representation and appends it. Equivalent to (sb_append sb (int_to_string n)) but reads more naturally in numeric formatting code.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_append_int, sb_to_string,
                                                   sb_free, StringBuilder

fn format_coords(x: int, y: int) -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "(")
    set sb (sb_append_int sb x)
    set sb (sb_append sb ", ")
    set sb (sb_append_int sb y)
    set sb (sb_append sb ")")
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow format_coords {
    assert (== (format_coords 10 20) "(10, 20)")
    assert (== (format_coords -5 0) "(-5, 0)")
}

---

sb_append_char


fn sb_append_char(sb: StringBuilder, c: int) -> StringBuilder

Appends a single character given by its ASCII integer value. Use character literals ('A', '\n') for readability.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_append_char, sb_to_string,
                                                   sb_free, StringBuilder

fn surround_with_quotes(s: string) -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append_char sb '"')
    set sb (sb_append sb s)
    set sb (sb_append_char sb '"')
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow surround_with_quotes {
    assert (== (surround_with_quotes "hello") "\"hello\"")
}

---

sb_to_string


fn sb_to_string(sb: StringBuilder) -> string

Returns the accumulated content as a plain string. Because the buffer is mutable, the returned string reflects all appends made up to the point of the call. You can call sb_to_string multiple times; each call returns a snapshot of the current content.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_to_string, sb_free, StringBuilder

fn snapshot_example() -> bool {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "Hello")
    let partial: string = (sb_to_string sb)  # "Hello"

    set sb (sb_append sb " World")
    let full: string = (sb_to_string sb)     # "Hello World"

    (sb_free sb)
    return (and (== partial "Hello") (== full "Hello World"))
}

shadow snapshot_example {
    assert (snapshot_example)
}

---

sb_length


fn sb_length(sb: StringBuilder) -> int

Returns the current number of bytes in the builder.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append,
                                                   sb_length, sb_free, StringBuilder

fn length_demo() -> bool {
    let mut sb: StringBuilder = (sb_new)
    assert (== (sb_length sb) 0)
    set sb (sb_append sb "hello")
    assert (== (sb_length sb) 5)
    set sb (sb_append sb " world")
    assert (== (sb_length sb) 11)
    (sb_free sb)
    return true
}

shadow length_demo {
    assert (length_demo)
}

---

sb_capacity


fn sb_capacity(sb: StringBuilder) -> int

Returns the current allocated buffer capacity in bytes. This is always at least as large as sb_length. Useful for diagnostics or pre-sizing decisions.

---

sb_clear


fn sb_clear(sb: StringBuilder) -> StringBuilder

Resets the builder's content to empty while preserving the allocated capacity. The returned value is the same StringBuilder handle. Useful for reusing a builder across multiple formatting passes without paying for reallocation.

**Example:**


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_clear,
                                                   sb_to_string, sb_length,
                                                   sb_free, StringBuilder

fn reuse_builder() -> bool {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "first pass")
    let first: string = (sb_to_string sb)

    set sb (sb_clear sb)
    assert (== (sb_length sb) 0)

    set sb (sb_append sb "second pass")
    let second: string = (sb_to_string sb)

    (sb_free sb)
    return (and (== first "first pass") (== second "second pass"))
}

shadow reuse_builder {
    assert (reuse_builder)
}

---

sb_is_empty


fn sb_is_empty(sb: StringBuilder) -> bool

Returns true if the builder contains no characters. Equivalent to (== (sb_length sb) 0) but more expressive.

---

sb_free


fn sb_free(sb: StringBuilder) -> void

Releases the underlying C buffer. Call this when you are finished with a StringBuilder that you created with sb_new or sb_with_capacity. After calling sb_free, do not use the builder again.

Builders created internally by sb_from_parts, sb_join, sb_repeat, and sb_indent are freed by those functions — do not free them yourself.

---

sb_from_parts


fn sb_from_parts(parts: array<string>) -> string

Efficiently concatenates all strings in parts in order and returns the result as a plain string. This is a standalone utility — it creates and frees its own internal builder, so you do not need to manage one yourself.

**Example:**


from "std/collections/stringbuilder.nano" import sb_from_parts

fn join_words() -> string {
    let words: array<string> = (array_new 4 "")
    (array_set words 0 "The")
    (array_set words 1 " ")
    (array_set words 2 "quick")
    (array_set words 3 " fox")
    return (sb_from_parts words)
}

shadow join_words {
    assert (== (join_words) "The quick fox")
}

---

sb_join


fn sb_join(parts: array<string>, separator: string) -> string

Concatenates all strings in parts, inserting separator between each adjacent pair. No separator is added before the first element or after the last.

**Example:**


from "std/collections/stringbuilder.nano" import sb_join

fn csv_row(fields: array<string>) -> string {
    return (sb_join fields ",")
}

shadow csv_row {
    let fields: array<string> = (array_new 3 "")
    (array_set fields 0 "alice")
    (array_set fields 1 "30")
    (array_set fields 2 "engineer")
    assert (== (csv_row fields) "alice,30,engineer")
}

---

sb_repeat


fn sb_repeat(text: string, n: int) -> string

Returns a string consisting of text repeated n times. When n is zero, returns "".

**Example:**


from "std/collections/stringbuilder.nano" import sb_repeat

fn make_divider(width: int) -> string {
    return (sb_repeat "-" width)
}

shadow make_divider {
    assert (== (make_divider 5) "-----")
    assert (== (make_divider 0) "")
}

---

sb_indent


fn sb_indent(level: int, spaces_per_level: int) -> string

Returns an indentation string of level * spaces_per_level spaces. Useful when generating code or structured text output.

**Example:**


from "std/collections/stringbuilder.nano" import sb_indent

fn indent_demo() -> bool {
    assert (== (sb_indent 0 4) "")
    assert (== (sb_indent 1 4) "    ")
    assert (== (sb_indent 2 4) "        ")
    assert (== (sb_indent 1 2) "  ")
    return true
}

shadow indent_demo {
    assert (indent_demo)
}

---

Examples

Example 1: HTML Generation


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_line,
                                                   sb_to_string, sb_free, StringBuilder

fn build_html_page(title: string, items: array<string>) -> string {
    let mut sb: StringBuilder = (sb_new)

    set sb (sb_append_line sb "<!DOCTYPE html>")
    set sb (sb_append_line sb "<html>")
    set sb (sb_append_line sb "<head>")
    set sb (sb_append     sb "  <title>")
    set sb (sb_append     sb title)
    set sb (sb_append_line sb "</title>")
    set sb (sb_append_line sb "</head>")
    set sb (sb_append_line sb "<body>")
    set sb (sb_append_line sb "  <ul>")

    let mut i: int = 0
    while (< i (array_length items)) {
        set sb (sb_append sb "    <li>")
        set sb (sb_append sb (at items i))
        set sb (sb_append_line sb "</li>")
        set i (+ i 1)
    }

    set sb (sb_append_line sb "  </ul>")
    set sb (sb_append_line sb "</body>")
    set sb (sb_append sb "</html>")

    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow build_html_page {
    let items: array<string> = (array_new 3 "")
    (array_set items 0 "Alpha")
    (array_set items 1 "Beta")
    (array_set items 2 "Gamma")
    let html: string = (build_html_page "My List" items)
    assert (str_contains html "<title>My List</title>")
    assert (str_contains html "<li>Alpha</li>")
    assert (str_contains html "<li>Gamma</li>")
}

Example 2: CSV Generation


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_line,
                                                   sb_append_int, sb_to_string,
                                                   sb_free, StringBuilder

struct Employee {
    name: string,
    age: int,
    department: string
}

fn employees_to_csv(employees: array<Employee>) -> string {
    let mut sb: StringBuilder = (sb_new)

    # Header row
    set sb (sb_append_line sb "name,age,department")

    # Data rows
    let mut i: int = 0
    while (< i (array_length employees)) {
        let emp: Employee = (at employees i)
        set sb (sb_append sb emp.name)
        set sb (sb_append sb ",")
        set sb (sb_append_int sb emp.age)
        set sb (sb_append sb ",")
        set sb (sb_append_line sb emp.department)
        set i (+ i 1)
    }

    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow employees_to_csv {
    let emps: array<Employee> = (array_new 2 Employee { name: "", age: 0, department: "" })
    (array_set emps 0 Employee { name: "Alice", age: 30, department: "Engineering" })
    (array_set emps 1 Employee { name: "Bob", age: 25, department: "Design" })
    let csv: string = (employees_to_csv emps)
    assert (str_contains csv "name,age,department")
    assert (str_contains csv "Alice,30,Engineering")
    assert (str_contains csv "Bob,25,Design")
}

Example 3: JSON Object Building


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_int,
                                                   sb_to_string, sb_free, StringBuilder

fn build_json_object(name: string, age: int, active: bool) -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append sb "{")
    set sb (sb_append sb "\"name\":\"")
    set sb (sb_append sb name)
    set sb (sb_append sb "\",")
    set sb (sb_append sb "\"age\":")
    set sb (sb_append_int sb age)
    set sb (sb_append sb ",")
    set sb (sb_append sb "\"active\":")
    if active {
        set sb (sb_append sb "true")
    } else {
        set sb (sb_append sb "false")
    }
    set sb (sb_append sb "}")
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow build_json_object {
    let json: string = (build_json_object "Alice" 30 true)
    assert (== json "{\"name\":\"Alice\",\"age\":30,\"active\":true}")
    let json2: string = (build_json_object "Bob" 25 false)
    assert (str_contains json2 "\"active\":false")
}

Example 4: Indented Code Generation


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_line,
                                                   sb_indent, sb_to_string,
                                                   sb_free, StringBuilder

fn generate_function(fn_name: string, body_lines: array<string>) -> string {
    let mut sb: StringBuilder = (sb_new)

    # Function signature
    set sb (sb_append sb "fn ")
    set sb (sb_append sb fn_name)
    set sb (sb_append_line sb "() -> void {")

    # Body lines, indented by 4 spaces
    let indent: string = (sb_indent 1 4)
    let mut i: int = 0
    while (< i (array_length body_lines)) {
        set sb (sb_append sb indent)
        set sb (sb_append_line sb (at body_lines i))
        set i (+ i 1)
    }

    # Closing brace
    set sb (sb_append sb "}")

    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow generate_function {
    let lines: array<string> = (array_new 2 "")
    (array_set lines 0 "let x: int = 42")
    (array_set lines 1 "(println x)")
    let code: string = (generate_function "example" lines)
    assert (str_contains code "fn example() -> void {")
    assert (str_contains code "    let x: int = 42")
    assert (str_contains code "    (println x)")
    assert (str_contains code "}")
}

Example 5: Building a Delimited Report

This example shows combining sb_join and sb_repeat to produce a formatted tabular report.


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_line,
                                                   sb_join, sb_repeat,
                                                   sb_to_string, sb_free, StringBuilder

fn make_table(headers: array<string>, rows: array<array<string>>) -> string {
    let mut sb: StringBuilder = (sb_new)
    let divider: string = (sb_repeat "-" 40)

    # Header
    set sb (sb_append_line sb divider)
    set sb (sb_append_line sb (sb_join headers " | "))
    set sb (sb_append_line sb divider)

    # Rows
    let mut i: int = 0
    while (< i (array_length rows)) {
        let row: array<string> = (at rows i)
        set sb (sb_append_line sb (sb_join row " | "))
        set i (+ i 1)
    }

    set sb (sb_append sb divider)
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

---

When to Use StringBuilder vs. Plain Concatenation

SituationRecommendation
Joining exactly 2 strings(+ a b) — simpler
Joining 3–5 strings outside a loopEither — use judgment
Concatenating in a loop of any sizeStringBuilder
Building strings from 10+ fragmentsStringBuilder
Generating HTML, CSV, JSON, codeStringBuilder
Building a simple error message(+ "Error: " msg)
Pre-known set of partssb_from_parts or sb_join

---

Common Pitfalls

**Forgetting to call sb_free.** The StringBuilder wraps a C heap allocation. For every sb_new or sb_with_capacity call, there must be a corresponding sb_free when the builder is no longer needed.

**Passing the wrong type.** sb_append only accepts string. Use sb_append_int for integers or sb_append_char for character values. For floats, call float_to_string explicitly before appending.


from "std/collections/stringbuilder.nano" import sb_new, sb_append, sb_append_int,
                                                   sb_to_string, sb_free, StringBuilder

fn format_ratio(num: int, den: int) -> string {
    let mut sb: StringBuilder = (sb_new)
    set sb (sb_append_int sb num)
    set sb (sb_append sb "/")
    set sb (sb_append_int sb den)
    let result: string = (sb_to_string sb)
    (sb_free sb)
    return result
}

shadow format_ratio {
    assert (== (format_ratio 3 4) "3/4")
}

**Using sb_clear instead of creating a new builder when capacity should reset.** sb_clear preserves the allocated capacity, which is desirable for reuse. If you want a truly fresh builder with a smaller footprint, call sb_free then sb_new.

**Pre-sizing for large outputs.** If you know you will be building a multi-kilobyte string, seed the capacity upfront with sb_with_capacity to avoid repeated reallocations.

---

**Previous:** 13.2 log

**Next:** Chapter 14: Data Formats