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
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 errorFixed-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 errorA fixed-width file read as fixed_parse reads text.
fixed_write(rows: list, spec: list, line_ending: str) → stror an errorrows (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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/fixed_width.qwer$ curl -O https://quillolang.org/plugins/files/fixed_width.qweThen, in your program, all of it or only what you call:
use fixed_width.qweruse fixed_parse, fixed_parse_file from fixed_width // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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 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)}Related
parquet.qwer · 1.0.0Parquet files for data lakes, DuckDB, Spark and pandas: rows written with their column types found, and read back as rows.6 views · 9 downloadsAvro avro.qwer · 1.0.0Apache Avro data files: rows written with a schema, as Kafka, Hadoop and BigQuery use, and read back with the schema they carry.9 views · 6 downloadsBibTeX bibtex.qwer · 1.0.0BibTeX and BibLaTeX bibliographies read, with LaTeX accents and macros resolved; reference lists in APA or IEEE style, CSL-JSON for Zotero and pandoc, and entries written back.7 views · 6 downloadsCSV csv.qwer · 1.0.0CSV in and out: rows as maps keyed by the header, with quotes, commas and line breaks in cells handled.8 views · 8 downloadsINI ini.qwer · 1.0.0INI files read and written, sections and keys: php.ini, setup.cfg, .gitconfig and the settings of older software.8 views · 7 downloadsISO 20022 bank files iso20022.qwer · 1.0.0ISO 20022 bank files: camt.053 statements (and camt.052 and 054) read into balances, entries and each payment's counterparty and reference, and pain.001 SEPA credit transfers built with checked IBANs and exact totals.7 views · 6 downloadsThread 0 posts
On Fixed width: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.