Data formats

Fixed width

Fixed-width records read and written from a column spec: bank payment files, payroll exports and mainframe formats.

Version 1.0.0 · 3 functions · 0 crates

#banking#import#export#files

Functions

What use fixed_width.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use fixed_parse, fixed_parse_file from fixed_width (Quillo 0.6).

fixed_parse(text: str, spec: list) → listor an error

Fixed-width text as rows of maps, one per line (empty lines skipped), cut by spec: a list of { name, width, type } in the order of the line, where type is "text" (trimmed), "int", "decimal" (with decimals for an implied point: 0001234 with 2 is "12.34", as text so cents stay exact), "date" (format YYYYMMDD, YYMMDD, DDMMYYYY, MMDDYYYY or DDMMYY; given as 2026-11-02, all zeros as null) or "filler" (skipped). Positions count characters. A line shorter than the spec gives empty fields; an error names the line and the field.

fixed_parse_file(path: str, spec: list) → listor an error

A fixed-width file read as fixed_parse reads text.

fixed_write(rows: list, spec: list, line_ending: str) → stror an error

rows (maps) as fixed-width text by spec (as fixed_parse takes it): text left-aligned and padded with spaces, ints and decimals right-aligned and padded with zeros (pad and align change that), decimals written with their decimals and no point, dates in their format, fillers as spaces. A value longer than its field is an error naming the row, never cut. Lines end with line_ending ("\n", or "\r\n" for most bank formats).

Get it

$ quillo install fixed_width               # fixed_width.qwer into this folder$ quillo install fixed_width --example     # and its example, fixed_width.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/fixed_width.qwer$ curl -O https://quillolang.org/plugins/files/fixed_width.qwe

Then, in your program, all of it or only what you call:

main.qwe
use fixed_width.qweruse fixed_parse, fixed_parse_file from fixed_width      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

fixed_width.qwe
// A payroll file for the bank in a fixed-width format, and the bank's return file read back.////   quillo run fixed_width.qwe      then open http://localhost:8080/payroll.txtuse "fixed_width.qwer"let SPEC = [    { name: "record", width: 2, type: "text" },    { name: "account", width: 34, type: "text" },    { name: "name", width: 24, type: "text" },    { name: "amount", width: 12, type: "decimal", decimals: 2 },    { name: "pay_date", width: 8, type: "date", format: "YYYYMMDD" },    { name: "spare", width: 6, type: "filler" },    { name: "reference", width: 10, type: "text", align: "right", pad: "0" },]let PAY = [    { record: "01", account: "NL91ABNA0417164300", name: "Ada Lovelace", amount: "4250.00", pay_date: "2026-11-25", reference: "1042" },    { record: "01", account: "GB29NWBK60161331926819", name: "Grace Hopper", amount: 3980.5, pay_date: "2026-11-25", reference: "1043" },]route GET /payroll.txt = respond(200, fixed_write(PAY, SPEC, "\r\n")?, { "content-type": "text/plain" })route GET /return {    let text = fixed_write(PAY, SPEC, "\r\n")?    fixed_parse(text, SPEC)?}route GET /too_long = { error: fixed_write([{ ...PAY[1], name: "Grace Brewster Murray Hopper" }], SPEC, "\n").error }
$ quillo run fixed_width.qwe$ curl 'localhost:8080/payroll.txt'$ curl 'localhost:8080/return'$ curl 'localhost:8080/too_long'

Source

fixed_width.qwer
//! Fixed-width records read and written from a column spec: bank payment files, payroll exports, mainframe and government formats where each field has its place on the line.//! version: 1.0.0//!//! [dependencies]use serde_json::{Map, Value};/// Files up to 100 MB and lines of up to 10,000 characters are read.const MAX: usize = 100 * 1024 * 1024;#[derive(Clone)]struct Field {    name: String,    width: usize,    kind: String,    decimals: usize,    format: String,    pad: Option<char>,    align_right: Option<bool>,}fn fields(spec: &[Value]) -> Result<Vec<Field>, String> {    if spec.is_empty() {        return Err("the spec lists the fields: [{ name, width, type }, ...]".into());    }    spec.iter().enumerate().map(|(i, f)| {        let name = f["name"].as_str().ok_or_else(|| format!("field {} has no name", i + 1))?.to_string();        let width = f["width"].as_i64().filter(|w| (1..=10_000).contains(w)).ok_or_else(|| format!("field `{}`: width is 1 to 10,000", name))? as usize;        let kind = f["type"].as_str().unwrap_or("text").to_string();        if !["text", "int", "decimal", "date", "filler"].contains(&kind.as_str()) {            return Err(format!("field `{}`: type is text, int, decimal, date or filler, not `{}`", name, kind));        }        Ok(Field {            name,            width,            decimals: f["decimals"].as_i64().unwrap_or(0).clamp(0, 18) as usize,            format: f["format"].as_str().unwrap_or("YYYYMMDD").to_string(),            pad: f["pad"].as_str().and_then(|p| p.chars().next()),            align_right: f["align"].as_str().map(|a| a == "right"),            kind,        })    }).collect()}fn read_value(f: &Field, raw: &str, line: usize) -> Result<Value, String> {    let t = raw.trim();    let bad = |what: &str| format!("line {}, field `{}`: `{}` is not {}", line, f.name, raw, what);    Ok(match f.kind.as_str() {        "text" => Value::String(t.to_string()),        "int" => {            if t.is_empty() { return Ok(Value::Null); }            Value::from(t.trim_start_matches('+').parse::<i64>().map_err(|_| bad("a whole number"))?)        }        "decimal" => {            if t.is_empty() { return Ok(Value::Null); }            let (neg, digits) = match t.strip_prefix('-') { Some(d) => (true, d), None => (false, t.trim_start_matches('+')) };            let text = if f.decimals > 0 && !digits.contains('.') && !digits.contains(',') {                if !digits.chars().all(|c| c.is_ascii_digit()) { return Err(bad("a number")); }                let d = format!("{:0>width$}", digits, width = f.decimals + 1);                format!("{}.{}", &d[..d.len() - f.decimals], &d[d.len() - f.decimals..])            } else {                let d = digits.replace(',', ".");                d.parse::<f64>().map_err(|_| bad("a number"))?;                d            };            let text = text.trim_start_matches('0').to_string();            let text = if text.starts_with('.') || text.is_empty() { format!("0{}", text) } else { text };            // Text, so cents stay exact: `dec(value)` makes it a decimal.            Value::String(if neg { format!("-{}", text) } else { text })        }        "date" => {            if t.is_empty() || t.chars().all(|c| c == '0') { return Ok(Value::Null); }            if !t.is_ascii() { return Err(bad("a date")); }            let (y, m, d) = match (f.format.as_str(), t.len()) {                ("YYYYMMDD", 8) => (t[0..4].to_string(), &t[4..6], &t[6..8]),                ("YYMMDD", 6) => (format!("20{}", &t[0..2]), &t[2..4], &t[4..6]),                ("DDMMYYYY", 8) => (t[4..8].to_string(), &t[2..4], &t[0..2]),                ("MMDDYYYY", 8) => (t[4..8].to_string(), &t[0..2], &t[2..4]),                ("DDMMYY", 6) => (format!("20{}", &t[4..6]), &t[2..4], &t[0..2]),                _ => return Err(bad(&format!("a date as {}", f.format))),            };            if !(y.chars().chain(m.chars()).chain(d.chars()).all(|c| c.is_ascii_digit())) { return Err(bad("a date")); }            Value::String(format!("{}-{}-{}", y, m, d))        }        _ => Value::Null,    })}/// Fixed-width text as rows of maps, one per line (empty lines skipped), cut by `spec`: a list of `{ name, width, type }`/// in the order of the line, where `type` is "text" (trimmed), "int", "decimal" (with `decimals` for an implied point:/// `0001234` with 2 is `"12.34"`, as text so cents stay exact), "date" (`format` YYYYMMDD, YYMMDD, DDMMYYYY, MMDDYYYY or/// DDMMYY; given as `2026-11-02`, all zeros as null) or "filler" (skipped). Positions count characters. A line shorter than/// the spec gives empty fields; an error names the line and the field.pub fn fixed_parse(text: &str, spec: Vec<serde_json::Value>) -> Result<Vec<Value>, String> {    if text.len() > MAX {        return Err("texts up to 100 MB".into());    }    let fs = fields(&spec)?;    let mut out = vec![];    for (n, line) in text.lines().enumerate() {        if line.trim().is_empty() {            continue;        }        let chars: Vec<char> = line.chars().take(10_000).collect();        let mut at = 0;        let mut row = Map::new();        for f in &fs {            let raw: String = chars.iter().skip(at).take(f.width).collect();            at += f.width;            if f.kind != "filler" {                row.insert(f.name.clone(), read_value(f, &raw, n + 1)?);            }        }        out.push(Value::Object(row));    }    Ok(out)}/// A fixed-width file read as fixed_parse reads text.pub fn fixed_parse_file(path: &str, spec: Vec<serde_json::Value>) -> Result<Vec<Value>, String> {    let size = std::fs::metadata(path).map_err(|e| format!("{}: {}", path, e))?.len();    if size as usize > MAX {        return Err(format!("{}: files up to 100 MB", path));    }    let bytes = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?;    let text = String::from_utf8(bytes).unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());    fixed_parse(&text, spec)}fn write_value(f: &Field, v: Option<&Value>, row: usize) -> Result<String, String> {    let bad = |what: String| format!("row {}, field `{}`: {}", row, f.name, what);    let v = v.filter(|v| !v.is_null());    let (text, right, pad) = match f.kind.as_str() {        "filler" => (String::new(), false, f.pad.unwrap_or(' ')),        "text" => (match v { Some(Value::String(s)) => s.clone(), Some(o) => o.to_string(), None => String::new() }, f.align_right.unwrap_or(false), f.pad.unwrap_or(' ')),        "int" | "decimal" => {            let Some(v) = v else { return Ok(f.pad.unwrap_or(' ').to_string().repeat(f.width)) };            let s = match v { Value::String(s) => s.trim().to_string(), o => o.to_string() };            let (neg, digits) = match s.strip_prefix('-') { Some(d) => (true, d.to_string()), None => (false, s.clone()) };            let digits = if f.kind == "int" {                digits.parse::<u64>().map_err(|_| bad(format!("`{}` is not a whole number", s)))?.to_string()            } else {                let x: f64 = digits.parse().map_err(|_| bad(format!("`{}` is not a number", s)))?;                let fixed = format!("{:.*}", f.decimals, x);                // Exact text is kept as written when it has no more decimals than allowed.                let exact = match digits.split_once('.') {                    Some((i, d)) if d.len() <= f.decimals => format!("{}.{:0<w$}", if i.is_empty() { "0" } else { i }, d, w = f.decimals),                    None if digits.chars().all(|c| c.is_ascii_digit()) => if f.decimals > 0 { format!("{}.{}", digits, "0".repeat(f.decimals)) } else { digits.clone() },                    _ => fixed,                };                exact.replace('.', "")            };            let pad = f.pad.unwrap_or('0');            let body = if pad == '0' && neg {                format!("-{:0>w$}", digits, w = f.width.saturating_sub(1))            } else {                format!("{}{}", if neg { "-" } else { "" }, digits)            };            (body, f.align_right.unwrap_or(true), pad)        }        "date" => {            let s = v.and_then(|v| v.as_str()).unwrap_or("");            if s.is_empty() {                (String::new(), false, f.pad.unwrap_or(' '))            } else {                if s.len() < 10 || !s.is_ascii() || &s[4..5] != "-" || &s[7..8] != "-" { return Err(bad(format!("`{}` is not a date like 2026-11-02", s))); }                let (y, m, d) = (&s[0..4], &s[5..7], &s[8..10]);                let t = match f.format.as_str() {                    "YYYYMMDD" => format!("{}{}{}", y, m, d),                    "YYMMDD" => format!("{}{}{}", &y[2..], m, d),                    "DDMMYYYY" => format!("{}{}{}", d, m, y),                    "MMDDYYYY" => format!("{}{}{}", m, d, y),                    "DDMMYY" => format!("{}{}{}", d, m, &y[2..]),                    other => return Err(bad(format!("unknown date format {}", other))),                };                (t, false, ' ')            }        }        _ => (String::new(), false, ' '),    };    let n = text.chars().count();    if n > f.width {        return Err(bad(format!("`{}` is {} characters, the field {}", text, n, f.width)));    }    let fill = pad.to_string().repeat(f.width - n);    Ok(if right { format!("{}{}", fill, text) } else { format!("{}{}", text, fill) })}/// `rows` (maps) as fixed-width text by `spec` (as fixed_parse takes it): text left-aligned and padded with spaces, ints and/// decimals right-aligned and padded with zeros (`pad` and `align` change that), decimals written with their `decimals` and/// no point, dates in their `format`, fillers as spaces. A value longer than its field is an error naming the row, never/// cut. Lines end with `line_ending` ("\n", or "\r\n" for most bank formats).pub fn fixed_write(rows: Vec<std::collections::HashMap<String, serde_json::Value>>, spec: Vec<serde_json::Value>, line_ending: &str) -> Result<String, String> {    let fs = fields(&spec)?;    let end = if line_ending.is_empty() { "\n" } else { line_ending };    let mut out = String::new();    for (i, r) in rows.iter().enumerate() {        for f in &fs {            out.push_str(&write_value(f, r.get(&f.name), i + 1)?);        }        out.push_str(end);    }    Ok(out)}
Thread
88Useful? Like it, or add to its thread below.

Thread 0 posts

On Fixed width: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.