Skip to content

← Collections, Text and First Iterators step 6 of 21

Medium Primitives

Zero-copy parsing: borrow, don't allocate

Parse an INI-style configuration into (section, key, value) triples. All three fields must be borrowed slices of the input text, not newly allocated Strings.

pub fn parse_ini(src: &str) -> Vec<(&str, &str, &str)>

For example, this input:

name = demo
[server]
host = example.com
; a comment
url = http://x?a=b

returns [("", "name", "demo"), ("server", "host", "example.com"), ("server", "url", "http://x?a=b")].

The rules

Process the input one line at a time, in order:

  1. Split src into lines with str::lines, which accepts both \n and \r\n line endings. Trim leading and trailing whitespace from each line with str::trim before applying the rules below.
  2. Skip a trimmed line that is empty or starts with # or ;.
  3. A trimmed line that starts with [ and ends with ] is a section header. The section name is the text between the brackets, trimmed, so [ s ] names section "s" and [] names section "". The header applies to every following pair until the next header.
  4. Otherwise, if the line contains =, split it at the first =. The key is the trimmed text before it and the value is the trimmed text after it. Either may be empty. url = http://x?a=b gives key url and value http://x?a=b.
  5. Any other line, such as junk or an unclosed [name, is ignored.

Pairs that appear before any header get the section "". Output follows input order. Repeated keys and repeated section names are all kept; nothing is merged or deduplicated. A # or ; later in a line does not start a comment, so k = v # note has the value v # note.

The allocation budget

One hidden case generates a 10 000-line configuration containing 8 900 pairs. The harness counts calls to the global allocator's alloc and realloc while parse_ini runs on that input, and the case fails if there are more than 20.

The budget is sized for the output Vec to grow, plus a little slack; it has no room for per-line or per-pair allocations. With the toolchain used to check this problem, an empty Vec that grows by push reached 8 900 entries after 13 allocator calls. The standard library does not promise a particular growth strategy, which is why the budget has slack. Calling Vec::with_capacity with the number of lines is also fine. Allocating one String per pair, even only for the section name, makes thousands of calls and fails.

Why the return type can borrow

A &str is a pointer to UTF-8 bytes plus a length (16 bytes on 64-bit targets). It does not own the bytes. The compiler checks that no such reference outlives the data it points into.

The signature does not name a lifetime, so the lifetime elision rules supply one. When a function has exactly one reference parameter, every elided lifetime in the return type is tied to it. Written out in full:

pub fn parse_ini<'a>(src: &'a str) -> Vec<(&'a str, &'a str, &'a str)>

So every returned slice must come from src. Without any reference parameter, as in fn parse_ini() -> Vec<(&str, &str, &str)>, there is nothing for the output to borrow from, and the compiler reports E0106, missing lifetime specifiers.

The errors in the starter

The starter already applies the rules above correctly. Its problem is that it keeps the current section in an owned String, and it does not compile:

error[E0506]: cannot assign to `section` because it is borrowed
error[E0515]: cannot return value referencing local variable `section`

E0515 explains the underlying problem. section is a local variable, and its heap buffer is freed when the function returns. Pushing section.as_str() into out stores a reference to that buffer in the return value, so the caller would receive references to freed memory. The compiler rejects the function instead.

E0506 comes from the same borrow. Once out holds a reference into section, assigning a new String to section would drop the buffer that reference points at, so the assignment is rejected too.

The repair is to stop owning the section name. src.lines() yields &str slices borrowed from src, and trimming or stripping brackets from one of them yields a smaller slice of src. Store that slice. That takes three small edits: start with let mut section = "";, assign the trimmed slice without .to_string(), and push section without .as_str(). Changing only the initializer is not enough: the .to_string() assignment then fails with E0308 (mismatched types), and as_str on a &str is not available on stable Rust (E0658).

Check your understanding If the tuple held an owned section, out.push((section.clone(), k.trim(), v.trim())), would that compile? Show answer

Only after changing the return type to Vec<(String, &str, &str)>. With that change the function compiles and returns correct values, because each tuple owns a copy of the section name and no longer borrows the local String. It would not pass this problem: the harness calls the required signature, and one clone per pair is far over the allocation budget.

The borrow checker rejects the version that would return dangling references. It does not reject a version that copies more than necessary.

Owned strings are the right choice when the parsed result must outlive the input text, for example when src will be dropped while the configuration is kept. Borrowed slices are the right choice when the input stays alive at least as long as the result, as it does here.

Useful str methods

  • split_once(pat) returns Option<(&str, &str)>: the text before the first match and the text after it, or None if there is no match. Clippy's manual_split_once lint flags some splitn(2, ...) patterns that this method expresses directly.
  • strip_prefix / strip_suffix return Option<&str> with the prefix or suffix removed, or None if it is absent. Chaining them recognizes [section] without manual index arithmetic.
  • trim returns a sub-slice of the same string with leading and trailing whitespace removed. It does not allocate.

Grading compiles your function with the harness, runs the test cases, and runs Clippy with warnings treated as errors.