///|
/// Parser configuration for delimited text.
pub(all) struct ParseConfig {
delimiter : Char
quote : Char
has_header : Bool
trim_unquoted : Bool
skip_empty_lines : Bool
strict_columns : Bool
comment : Char?
} derive(Eq)
///|
/// Structured parse error with a stable code and source position.
pub(all) struct ParseError {
code : String
message : String
line : Int
column : Int
} derive(Eq)
///|
/// A parsed delimited table.
pub(all) struct Table {
header : Array[String]
rows : Array[Array[String]]
} derive(Eq)
///|
/// A row represented as header-name/value pairs.
pub(all) struct RowRecord {
pairs : Array[(String, String)]
} derive(Eq)
///|
/// Supported validation kinds for schema rules.
pub(all) enum FieldKind {
Text
NonEmpty
Integer
Decimal
Boolean
} derive(Eq)
///|
/// One column-level validation rule.
pub(all) struct ColumnRule {
name : String
kind : FieldKind
required : Bool
} derive(Eq)
///|
/// A validation schema for header-based tables.
pub(all) struct Schema {
columns : Array[ColumnRule]
allow_extra_columns : Bool
} derive(Eq)
///|
/// One validation problem.
pub(all) struct ValidationError {
row : Int
column : String
code : String
message : String
} derive(Eq)
///|
/// Validation result. Empty `errors` means the table is valid.
pub(all) struct ValidationReport {
errors : Array[ValidationError]
} derive(Eq)
///|
/// RFC-4180 style CSV defaults.
pub fn csv_config() -> ParseConfig {
{
delimiter: ',',
quote: '"',
has_header: true,
trim_unquoted: false,
skip_empty_lines: true,
strict_columns: true,
comment: None,
}
}
///|
/// TSV defaults. Quoted fields are still supported for embedded tabs/newlines.
pub fn tsv_config() -> ParseConfig {
{
delimiter: '\t',
quote: '"',
has_header: true,
trim_unquoted: false,
skip_empty_lines: true,
strict_columns: true,
comment: None,
}
}
///|
/// Build a required column rule.
pub fn required_column(name : String, kind : FieldKind) -> ColumnRule {
{ name, kind, required: true }
}
///|
/// Build an optional column rule.
pub fn optional_column(name : String, kind : FieldKind) -> ColumnRule {
{ name, kind, required: false }
}
///|
/// Build a closed schema: table headers must be covered by the schema.
pub fn closed_schema(columns : Array[ColumnRule]) -> Schema {
{ columns, allow_extra_columns: false }
}
///|
/// Build an open schema: extra headers are allowed.
pub fn open_schema(columns : Array[ColumnRule]) -> Schema {
{ columns, allow_extra_columns: true }
}
///|
/// Parse text with CSV defaults.
pub fn parse_csv(input : String) -> Result[Table, ParseError] {
parse_with(input, csv_config())
}
///|
/// Parse text with TSV defaults.
pub fn parse_tsv(input : String) -> Result[Table, ParseError] {
parse_with(input, tsv_config())
}
///|
/// Number of data rows, excluding the header.
pub fn Table::row_count(self : Table) -> Int {
self.rows.length()
}
///|
/// Number of columns. Header width is preferred when present.
pub fn Table::column_count(self : Table) -> Int {
if self.header.length() > 0 {
self.header.length()
} else if self.rows.length() > 0 {
self.rows[0].length()
} else {
0
}
}
///|
/// Return a cell by zero-based row and column indexes.
pub fn Table::cell(self : Table, row : Int, column : Int) -> String? {
match self.rows.get(row) {
Some(values) => values.get(column)
None => None
}
}
///|
/// Find a header column by name.
pub fn Table::find_column(self : Table, name : String) -> Int? {
let mut i = 0
while i < self.header.length() {
if self.header[i] == name {
return Some(i)
}
i = i + 1
}
None
}
///|
/// Return a cell by row index and header name.
pub fn Table::cell_by_name(self : Table, row : Int, name : String) -> String? {
match self.find_column(name) {
Some(column) => self.cell(row, column)
None => None
}
}
///|
/// Convert each row to a name/value record. Missing cells become empty strings.
pub fn Table::to_records(self : Table) -> Array[RowRecord] {
let records : Array[RowRecord] = []
let mut r = 0
while r < self.rows.length() {
let row = self.rows[r]
let pairs : Array[(String, String)] = []
let mut c = 0
while c < self.header.length() {
let value = match row.get(c) {
Some(v) => v
None => ""
}
pairs.push((self.header[c], value))
c = c + 1
}
records.push({ pairs, })
r = r + 1
}
records
}
///|
/// Look up a value from a row record.
pub fn RowRecord::get(self : RowRecord, name : String) -> String? {
for pair in self.pairs {
let (key, value) = pair
if key == name {
return Some(value)
}
}
None
}
///|
/// Human-readable table summary used by examples and CLI smoke tests.
pub fn Table::summary(self : Table) -> String {
let text =
$|rows=\{self.row_count()}, columns=\{self.column_count()}
text
}
///|
fn parse_error(
code : String,
message : String,
line : Int,
column : Int,
) -> ParseError {
{ code, message, line, column }
}
///|
fn is_line_break(c : Char) -> Bool {
c == '\n' || c == '\r'
}
///|
fn field_value(raw : String, quoted : Bool, trim_unquoted : Bool) -> String {
if quoted || !trim_unquoted {
raw
} else {
raw.trim().to_owned()
}
}
///|
fn is_blank_row(row : Array[String]) -> Bool {
for field in row {
if !field.trim().is_empty() {
return false
}
}
true
}
///|
fn push_row(
rows : Array[Array[String]],
row : Array[String],
skip_empty_lines : Bool,
) -> Unit {
if skip_empty_lines && is_blank_row(row) {
()
} else {
rows.push(row)
}
}
///|
fn validate_header(header : Array[String]) -> ParseError? {
let mut i = 0
while i < header.length() {
if header[i].trim().is_empty() {
let msg =
$|header column \{i + 1} is empty
return Some(parse_error("empty_header", msg, 1, i))
}
let mut j = i + 1
while j < header.length() {
if header[i] == header[j] {
let msg =
$|duplicate header '\{header[i]}' at column \{j + 1}
return Some(parse_error("duplicate_header", msg, 1, j))
}
j = j + 1
}
i = i + 1
}
None
}
///|
fn build_table(
rows : Array[Array[String]],
config : ParseConfig,
) -> Result[Table, ParseError] {
let header : Array[String] = []
let data : Array[Array[String]] = []
if rows.length() == 0 {
return Ok({ header, rows: data })
}
let expected = if config.has_header {
let first = rows[0]
let mut c = 0
while c < first.length() {
header.push(first[c])
c = c + 1
}
match validate_header(header) {
Some(err) => return Err(err)
None => ()
}
header.length()
} else {
rows[0].length()
}
let start = if config.has_header { 1 } else { 0 }
let mut r = start
while r < rows.length() {
let row = rows[r]
if config.strict_columns && row.length() != expected {
let msg =
$|row \{r + 1} has \{row.length()} columns, expected \{expected}
return Err(parse_error("column_count", msg, r + 1, row.length()))
}
data.push(row)
r = r + 1
}
Ok({ header, rows: data })
}
///|
/// Parse delimited text with a custom configuration.
pub fn parse_with(
input : String,
config : ParseConfig,
) -> Result[Table, ParseError] {
let rows : Array[Array[String]] = []
let mut row : Array[String] = []
let field = StringBuilder::StringBuilder()
let mut in_quotes = false
let mut quoted = false
let mut after_quote = false
let mut field_chars = 0
let mut i = 0
let mut line = 1
let mut column = 0
let mut at_row_start = true
while i < input.length() {
let c = match input.get_char(i) {
Some(ch) => ch
None =>
return Err(
parse_error("invalid_char", "invalid character offset", line, column),
)
}
if at_row_start && field_chars == 0 && row.length() == 0 {
match config.comment {
Some(marker) =>
if c == marker {
while i < input.length() {
let skip = match input.get_char(i) {
Some(ch) => ch
None => '\n'
}
i = i + 1
if skip == '\n' {
line = line + 1
column = 0
break
} else if skip == '\r' {
if i < input.length() && input.get_char(i) == Some('\n') {
i = i + 1
}
line = line + 1
column = 0
break
}
}
continue
}
None => ()
}
}
if in_quotes {
if c == config.quote {
if i + 1 < input.length() && input.get_char(i + 1) == Some(config.quote) {
field.write_char(config.quote)
field_chars = field_chars + 1
i = i + 2
column = column + 2
} else {
in_quotes = false
after_quote = true
i = i + 1
column = column + 1
}
} else {
field.write_char(c)
field_chars = field_chars + 1
i = i + 1
if c == '\n' {
line = line + 1
column = 0
} else if c == '\r' {
if i < input.length() && input.get_char(i) == Some('\n') {
field.write_char('\n')
i = i + 1
}
line = line + 1
column = 0
} else {
column = column + 1
}
}
} else if after_quote {
if c == config.delimiter {
row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
field.reset()
quoted = false
after_quote = false
field_chars = 0
at_row_start = false
i = i + 1
column = column + 1
} else if is_line_break(c) {
row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
field.reset()
quoted = false
after_quote = false
field_chars = 0
push_row(rows, row, config.skip_empty_lines)
row = []
at_row_start = true
i = i + 1
if c == '\r' && i < input.length() && input.get_char(i) == Some('\n') {
i = i + 1
}
line = line + 1
column = 0
} else if c == ' ' || c == '\t' {
if config.trim_unquoted {
i = i + 1
column = column + 1
} else {
return Err(
parse_error(
"text_after_quote", "unexpected whitespace after closing quote", line,
column,
),
)
}
} else {
return Err(
parse_error(
"text_after_quote", "unexpected text after closing quote", line, column,
),
)
}
} else if c == config.quote {
if field_chars == 0 {
in_quotes = true
quoted = true
at_row_start = false
i = i + 1
column = column + 1
} else {
return Err(
parse_error(
"bare_quote", "quote appears inside an unquoted field", line, column,
),
)
}
} else if c == config.delimiter {
row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
field.reset()
quoted = false
field_chars = 0
at_row_start = false
i = i + 1
column = column + 1
} else if is_line_break(c) {
row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
field.reset()
quoted = false
field_chars = 0
push_row(rows, row, config.skip_empty_lines)
row = []
at_row_start = true
i = i + 1
if c == '\r' && i < input.length() && input.get_char(i) == Some('\n') {
i = i + 1
}
line = line + 1
column = 0
} else {
field.write_char(c)
field_chars = field_chars + 1
at_row_start = false
i = i + 1
column = column + 1
}
}
if in_quotes {
return Err(
parse_error("unclosed_quote", "quoted field is not closed", line, column),
)
}
if field_chars > 0 || quoted || row.length() > 0 || !config.skip_empty_lines {
row.push(field_value(field.to_string(), quoted, config.trim_unquoted))
push_row(rows, row, config.skip_empty_lines)
}
build_table(rows, config)
}