package Cepis::CSV; use strict; use warnings; our $VERSION = '0.1.0'; sub parse_rows { my ($text) = @_; return [] unless defined $text && length $text; $text =~ s/\A\x{FEFF}//; my @rows; my @row; my $field = ''; my $quoted = 0; my $started = 0; my $length = length $text; my $i = 0; while ( $i < $length ) { my $char = substr( $text, $i, 1 ); if ($quoted) { if ( $char eq '"' ) { if ( $i + 1 < $length && substr( $text, $i + 1, 1 ) eq '"' ) { $field .= '"'; $i += 2; next; } $quoted = 0; $i++; next; } $field .= $char; $i++; next; } if ( $char eq '"' && $field eq '' ) { $quoted = 1; $started = 1; $i++; next; } if ( $char eq ',' ) { push @row, $field; $field = ''; $started = 1; $i++; next; } if ( $char eq "\r" || $char eq "\n" ) { $i++; $i++ if $char eq "\r" && $i < $length && substr( $text, $i, 1 ) eq "\n"; if ( $started || @row ) { push @row, $field; push @rows, [@row]; } @row = (); $field = ''; $started = 0; next; } $field .= $char; $started = 1; $i++; } if ( $started || @row ) { push @row, $field; push @rows, [@row]; } return \@rows; } sub parse_hashes { my ($text) = @_; my $rows = parse_rows($text); return ( [], [] ) unless @$rows; my $header = shift @$rows; my @out; for my $row (@$rows) { my %record; for my $index ( 0 .. $#$header ) { my $value = $index <= $#$row ? $row->[$index] : ''; $record{ $header->[$index] } = defined $value ? $value : ''; } push @out, \%record; } return ( $header, \@out ); } 1; __END__ =encoding UTF-8 =head1 NAME Cepis::CSV - a small RFC 4180 reader =head1 SYNOPSIS use Cepis::CSV; my ( $header, $records ) = Cepis::CSV::parse_hashes($text); print $records->[0]{localidade}; =head1 DESCRIPTION Perl ships no CSV parser in its core, and this distribution has no non-core dependencies, so it carries its own. It handles what RFC 4180 requires and what the C endpoint actually emits: quoted fields, embedded commas, embedded newlines, C<""> as an escaped quote, C or C line endings, and a leading byte order mark. The input is a character string, not bytes — decode the response body before calling in. This module is internal to L. It is documented because it is tested separately, not because it is meant to be a general-purpose CSV library: it has no writer, no configurable separator and no streaming interface. =head1 FUNCTIONS =head2 parse_rows my $rows = Cepis::CSV::parse_rows($text); Returns an arrayref of arrayrefs, one per record, fields in order. Blank lines are skipped, so a trailing newline does not produce a phantom final record. =head2 parse_hashes my ( $header, $records ) = Cepis::CSV::parse_hashes($text); Treats the first record as a header row and returns it as an arrayref, plus an arrayref of hashrefs keyed by column name. A record with fewer fields than the header gets empty strings for the ones it lacks; fields beyond the header are dropped. Returns two empty arrayrefs for empty input. =head1 SEE ALSO L =head1 LICENCE ISC. Copyright (c) 2026 Murilo Ijanc Emurilo@ijanc.orgE. =cut