// Copyright 2026 PaiGack
// Licensed under the Apache License, Version 2.0.
// Ported from jlaffaye/ftp (ISC License), see LICENSE-THIRD-PARTY.
// Layer: pure logic — no IO, no `moonbitlang/async` dependency.
// LIST / MLSD line parsers and their shared helpers.
//
// This file merges `parse`, `parse_time` and `scanner`: the whitespace field
// scanner exists only to feed the parsers, and the time-field parser is only
// called from the LIST/MLSD/UNIX paths below. Splitting them meant three files
// that were never read apart, while the fallback chain that ties them together
// was spread across all three.
// The `LIST` / `MLSD` line parsers.
//
// This file merges `parse` (the dispatcher and the shared numeric helpers),
// `parse_rfc3659`, `parse_unix_ls`, `parse_dos_dir` and `parse_hostedftp`.
// They are one unit: `parse_list_line` below is a fixed fallback chain over
// exactly those four formats, and every parser is unreachable except through
// it. Splitting them meant a reader had to open five files to follow one line
// of a directory listing.
//
// The fallback order is load bearing and documented in
// `docs/porting/02-upstream-map.md`; changing it changes which parser wins for
// an ambiguous line.
///|
/// Which of the four parsers recognised a listing line. The order of the
/// variants is the fallback order used by `parse_list_line`.
pub(all) enum ListFormat {
/// RFC 3659 machine readable facts (`type=file;size=...;modify=...;name`).
Rfc3659
/// `ls -l` style (also used by most UNIX servers and WFTPD).
UnixLs
/// MS-DOS / Windows `DIR` style.
DosDir
/// hostedftp.com style (`ls -l` without the link count).
HostedFtp
} derive(Eq, @debug.Debug)
///|
/// Parse a single `LIST` / `MLSD` line, trying the four parsers in the fixed
/// fallback order documented in `docs/porting/02-upstream-map.md`.
///
/// `line` must not contain the trailing CR/LF. `now` is the reference instant
/// used for the "no year" heuristic and is an explicit parameter so that the
/// upstream test cases (fixed at 2017-03-10 23:00 UTC) stay reproducible.
pub fn parse_list_line(
line : String,
now : @time.ZonedDateTime,
) -> (Entry, ListFormat) raise FtpError {
match parse_rfc3659_line(line) {
Some(entry) => return (entry, Rfc3659)
None => ()
}
match parse_ls_line(line, now) {
Some(entry) => return (entry, UnixLs)
None => ()
}
match parse_dos_dir_line(line, now) {
Some(entry) => return (entry, DosDir)
None => ()
}
match parse_hostedftp_line(line, now) {
Some(entry) => return (entry, HostedFtp)
None => ()
}
raise FtpError::UnsupportedListLine(line~)
}
///|
/// Parse a numeric size field the way Go's `strconv.ParseUint(s, 0, 64)` does:
/// an explicit `0x` / `0X` prefix means hex, `0o` / `0O` means octal,
/// a leading `0` means octal, and anything else is decimal.
pub fn set_size(raw : String) -> UInt64 raise FtpError {
let text = raw.trim().to_owned()
guard text != "" else { raise FtpError::ParseError(msg="empty size field") }
let (digits, radix) : (String, Int) = if text.length() > 2 &&
(text[0:2] == "0x" || text[0:2] == "0X") {
(text[2:].to_owned(), 16)
} else if text.length() > 2 && (text[0:2] == "0o" || text[0:2] == "0O") {
(text[2:].to_owned(), 8)
} else if text.length() > 1 && text[0] == '0' {
(text, 8)
} else {
(text, 10)
}
parse_uint(digits, radix) catch {
_ => raise FtpError::ParseError(msg="invalid size field: \{raw}")
}
}
///|
/// Parse an unsigned 64 bit integer in the given radix, rejecting empty input
/// and overflowing values.
pub fn parse_uint(digits : String, radix : Int) -> UInt64 raise FtpError {
guard digits != "" else { raise FtpError::ParseError(msg="empty number") }
let mut value : UInt64 = 0UL
for i = 0; i < digits.length(); i = i + 1 {
let code = digits.unsafe_get(i).to_int()
let digit = if code >= 48 && code <= 57 {
code - 48
} else if code >= 97 && code <= 102 {
code - 87
} else if code >= 65 && code <= 70 {
code - 55
} else {
raise FtpError::ParseError(msg="invalid digit")
}
guard digit < radix else { raise FtpError::ParseError(msg="invalid digit") }
value = value * radix.to_uint64() + digit.to_uint64()
}
value
}
///|
/// Parse a single RFC 3659 (`MLSD` / `MLST`) fact line:
///
/// ```text
/// type=file;size=951;modify=20140101000000; welcome.msg
/// ```
///
/// Faithful port of upstream `parseRFC3659ListLine`:
///
/// - the facts are separated by `;`, the last one is followed by the name,
/// - fact keys are matched case-insensitively (`Type=` works as well, which is
/// what WFTPD/Serv-U emit),
/// - `sizd` is accepted as a typo for `size`,
/// - an unrecognised `type=` value is an error, not a fallback,
/// - a line whose last fact is not terminated by `;` is not an RFC 3659 line.
pub fn parse_rfc3659_line(line : String) -> Entry? raise FtpError {
guard line != "" else { return None }
let entry = make_entry("", EntryType::File)
let len = line.length()
let mut i = 0
let mut have_facts = false
while i < len {
let field_start = i
// Scan to the next `;`, remembering where the first `=` was.
let mut eq = -1
while i < len && line.unsafe_get(i).to_int() != 59 {
if line.unsafe_get(i).to_int() == 61 && eq < 0 {
eq = i
}
i += 1
}
if i >= len {
// Trailing content without a `;` terminator: the whole line is the
// "facts" only if everything was consumed; otherwise this is not an
// RFC 3659 line at all.
if have_facts == false {
return None
}
break
}
let key = line[field_start:if eq >= 0 { eq } else { i }].to_owned()
let value = if eq >= 0 { line[eq + 1:i].to_owned() } else { "" }
match key.trim().to_lower() {
"" => ()
"type" => {
let kind = match value.to_lower() {
"file" => EntryType::File
"dir" | "cdir" | "pdir" => EntryType::Folder
"link" => EntryType::Link
_ => raise FtpError::ParseError(msg="unknown entry type")
}
entry.set_type(kind)
}
"size" | "sizd" => entry.set_size(set_size(value))
"modify" =>
entry.set_time(parse_rfc3659_datetime(value, entry.time.zone()))
_ => ()
}
have_facts = true
// Consume the separator itself.
i += 1
// Everything after the `; ` is the name; stop scanning facts.
if i < len && line.unsafe_get(i).to_int() == 32 {
i += 1
break
}
}
guard have_facts else { return None }
entry.set_name(line[i:].to_owned())
Some(entry)
}
///|
/// Parse a `YYYYMMDDHHMMSS` timestamp as used by RFC 3659 `modify=` facts.
/// Falls back to the value already stored in the entry when the shape does not
/// match, which keeps `MLST` output without a `modify` fact usable.
fn parse_rfc3659_datetime(
value : String,
zone : @time.Zone,
) -> @time.ZonedDateTime {
let digits = value.trim().to_owned()
guard digits.length() >= 14 else { return epoch }
let year = parse_fixed_digits(digits, 0, 4)
let month = parse_fixed_digits(digits, 4, 2)
let day = parse_fixed_digits(digits, 6, 2)
let hour = parse_fixed_digits(digits, 8, 2)
let minute = parse_fixed_digits(digits, 10, 2)
let second = parse_fixed_digits(digits, 12, 2)
match (year, month, day, hour, minute, second) {
(Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) =>
@time.ZonedDateTime::of(year, month, day, hour~, minute~, second~, zone~) catch {
_ => epoch
}
_ => epoch
}
}
///|
/// Merge the facts of a continuation line into the previous RFC 3659 entry.
/// Some servers answer `MLST` with the same entry spread over several lines;
/// upstream `parseNextRFC3659ListLine` requires the names to match.
pub fn parse_next_rfc3659_line(
entry : Entry,
line : String,
) -> Entry raise FtpError {
let next = parse_rfc3659_line(line)
guard next is Some(next) else { raise FtpError::UnsupportedListLine(line~) }
guard next.name == entry.name else {
raise FtpError::UnsupportedListLine(line~)
}
next
}
///|
/// Parse a Unix `ls -l` style line:
///
/// ```text
/// drwxr-xr-x 3 110 1002 3 Dec 02 2009 pub
/// -rw-r--r-- 1 marketwired marketwired 12016 Mar 16 2016 newsml
/// drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!
/// ```
///
/// Port of upstream `parseLsListLine`:
///
/// - the permission field is 10 bytes, or 11 with an ACL `+` marker,
/// - the type comes from the first character,
/// - the size is the **last** numeric field before the date, which is what
/// makes the `ls -l` and the "no link count" variants both parse,
/// - the date is found by scanning for `MMM DD `, so extra columns do not
/// shift the parse,
/// - the name is the entire remainder, so spaces inside names survive,
/// - a symlink's ` -> ` is split into name and target.
pub fn parse_ls_line(
line : String,
now : @time.ZonedDateTime,
) -> Entry? raise FtpError {
guard line.length() >= 10 else { return None }
let perms = line[0:10].to_owned()
let type_ = match perms[0] {
'd' => EntryType::Folder
'-' | 'f' => EntryType::File
'l' => EntryType::Link
_ => return None
}
// An 11th byte is only allowed as the ACL `+` marker or a separator.
if line.length() > 10 {
let marker = line.unsafe_get(10).to_int()
if marker != 43 && marker != 32 && marker != 9 {
return None
}
}
for i = 1; i < 10; i = i + 1 {
let c = perms.unsafe_get(i).to_int()
let ok = c == 114 || // r
c == 119 || // w
c == 120 || // x
c == 115 || // s
c == 116 || // t
c == 83 ||
c == 84 ||
c == 45 // -
if ok == false {
return None
}
}
// The date field is the anchor: everything before it holds the link count,
// owner, group and size; everything after it is the name.
let date_start = match find_date_start(line, 10) {
Some(index) => index
None => return None
}
let head = line[10:date_start].to_owned()
let size = match last_numeric_field(head) {
Some(value) => value
None => return None
}
let entry = make_entry("", type_, time=now)
entry.set_size(size)
let date_and_name = line[date_start:].to_owned()
let parsed = parse_ls_time(date_and_name, 0, now)
guard parsed is Some((timestamp, date_end)) else { return None }
entry.set_time(timestamp)
let raw_name = date_and_name[date_end:].to_owned()
let name = strip_leading_spaces(raw_name)
guard name != "" else { return None }
match split_link(name) {
Some((name, target)) => {
entry.set_name(name)
entry.set_target(target)
}
None => entry.set_name(name)
}
Some(entry)
}
///|
/// Index of the first `MMM DD ` date field at or after `start`.
pub fn find_date_start_pub(line : String, start : Int) -> Int? {
find_date_start(line, start)
}
///|
fn find_date_start(line : String, start : Int) -> Int? {
let mut i = start
while i + 7 <= line.length() {
if parse_month(line[i:i + 3].to_owned()) is Some(_) &&
line.unsafe_get(i + 3).to_int() == 32 &&
is_digit_at(line, i + 4) {
return Some(i)
}
i += 1
}
None
}
///|
/// The last whitespace separated field before the date that looks like a size:
/// a plain number, a `0x` / `0o` prefixed number, or the `folder` marker that
/// some servers emit in place of a size.
fn last_numeric_field(head : String) -> UInt64? {
let scanner = Scanner::new(head)
let mut found : UInt64? = None
while true {
let field = scanner.next()
if field == "" {
break
}
if field == "folder" {
found = Some(0UL)
} else if is_unsigned_number(field) {
found = Some(set_size(field) catch { _ => 0UL })
}
}
found
}
///|
/// Whether `value` looks like a number accepted by `set_size`.
fn is_unsigned_number(value : String) -> Bool {
guard value != "" else { return false }
let mut start = 0
if value.length() > 2 && (value[0:2] == "0x" || value[0:2] == "0o") {
start = 2
}
guard start < value.length() else { return false }
for i = start; i < value.length(); i = i + 1 {
let c = value.unsafe_get(i).to_int()
if c < 48 || c > 57 {
return false
}
}
true
}
///|
/// Whether `src[i]` is an ASCII digit.
fn is_digit_at(src : String, i : Int) -> Bool {
guard i < src.length() else { return false }
let code = src.unsafe_get(i).to_int()
code >= 48 && code <= 57
}
///|
/// Offset of the first non-space byte at or after `start`.
fn leading_field_offset(src : String, start : Int) -> Int {
let mut i = start
while i < src.length() && src.unsafe_get(i).to_int() == 32 {
i += 1
}
i
}
///|
/// Drop leading spaces from `src`.
fn strip_leading_spaces(src : String) -> String {
src[leading_field_offset(src, 0):].to_owned()
}
///|
/// Split `name -> target` on the **first** ` -> ` occurrence, as upstream does
/// with `strings.SplitN(..., " -> ", 2)`.
fn split_link(name : String) -> (String, String)? {
let marker = " -> "
let mut i = 0
while i + marker.length() <= name.length() {
if name[i:i + marker.length()] == marker {
return Some((name[0:i].to_owned(), name[i + marker.length():].to_owned()))
}
i += 1
}
None
}
///|
/// Parse a MS-DOS / Windows `DIR` style line:
///
/// ```text
/// 07-27-17 04:50PM 1264086 File.txt
/// 11-06-16 09:31AM Softlib
/// ```
///
/// Faithful port of upstream `parseDirListLine`:
///
/// - the first field is the date and is tried against four layouts
/// (`01-02-06 03:04PM` with one or two digit month/day/year, and the
/// `01-02-06 15:04` 24 hour variant),
/// - `` switches the type to folder and the size to 0,
/// - anything else must be a plain decimal size.
pub fn parse_dos_dir_line(
line : String,
now : @time.ZonedDateTime,
) -> Entry? raise FtpError {
guard line != "" else { return None }
let scanner = Scanner::new(line)
let date_field = scanner.next()
guard date_field != "" else { return None }
let time_field = scanner.next()
guard time_field != "" else { return None }
let parsed = parse_dos_date(date_field, time_field, now)
guard parsed is Some(timestamp) else { return None }
let size_field = scanner.next()
guard size_field != "" else { return None }
let rest = strip_leading_spaces(scanner.remaining())
guard rest != "" else { return None }
let entry = make_entry("", EntryType::File, time=timestamp)
if size_field == "" {
entry.set_type(EntryType::Folder)
entry.set_size(0)
} else {
guard is_unsigned_number(size_field) else { return None }
entry.set_size(set_size(size_field))
}
entry.set_name(rest)
Some(entry)
}
///|
/// Try the four DOS date/time layouts upstream accepts. Returns `None` when
/// none of them matches, which lets `parse_list_line` fall through to the
/// hostedftp parser (or to `UnsupportedListLine`).
fn parse_dos_date(
date_field : String,
time_field : String,
now : @time.ZonedDateTime,
) -> @time.ZonedDateTime? raise FtpError {
// Layout A: "01-02-06 03:04PM"
match parse_dos_parts(date_field, time_field, true) {
Some((month, day, year, hour, minute)) =>
return Some(resolve_dos_year(month, day, year, hour, minute, now))
None => ()
}
match parse_dos_parts(date_field, time_field, false) {
Some((month, day, year, hour, minute)) =>
return Some(resolve_dos_year(month, day, year, hour, minute, now))
None => ()
}
None
}
///|
/// DOS dates carry a two digit year. Upstream maps them into the current
/// century by adding 2000, which is what every server in the wild does.
fn resolve_dos_year(
month : Int,
day : Int,
year : Int,
hour : Int,
minute : Int,
now : @time.ZonedDateTime,
) -> @time.ZonedDateTime raise FtpError {
let full_year = if year < 100 { year + 2000 } else { year }
new_datetime(full_year, month, day, hour, minute, now.zone())
}
///|
/// Split `MM-DD-YY` and `HH:MM[AP]M` into numbers. When `require_ampm` is set
/// the time field must carry the `AM`/`PM` suffix (which is then applied).
fn parse_dos_parts(
date_field : String,
time_field : String,
require_ampm : Bool,
) -> (Int, Int, Int, Int, Int)? {
let mut i = 0
let month = parse_digits(date_field, i, 2)
guard month is Some((month, next)) else { return None }
guard next < date_field.length() && date_field.unsafe_get(next).to_int() == 45 else {
return None
}
i = next + 1
let day = parse_digits(date_field, i, 2)
guard day is Some((day, next)) else { return None }
guard next < date_field.length() && date_field.unsafe_get(next).to_int() == 45 else {
return None
}
i = next + 1
let year = parse_digits(date_field, i, 4)
guard year is Some((year, next)) else { return None }
guard next == date_field.length() else { return None }
let hour = parse_digits(time_field, 0, 2)
guard hour is Some((hour, next)) else { return None }
guard next < time_field.length() && time_field.unsafe_get(next).to_int() == 58 else {
return None
}
let minute = parse_digits(time_field, next + 1, 2)
guard minute is Some((minute, next)) else { return None }
let suffix = time_field[next:].to_owned().trim().to_upper()
let hour = if suffix == "PM" {
if hour == 12 {
12
} else {
hour + 12
}
} else if suffix == "AM" {
if hour == 12 {
0
} else {
hour
}
} else if require_ampm || suffix != "" {
return None
} else {
hour
}
guard month >= 1 && month <= 12 && day >= 1 && day <= 31 else { return None }
Some((month, day, year, hour, minute))
}
///|
/// Parse a hostedftp.com style line, which is `ls -l` without the link count:
///
/// ```text
/// drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!
/// ```
///
/// Upstream `parseHostedFTPLine` rewrites the first field by appending a
/// space and the literal link count `0`, then delegates to the Unix `ls`
/// parser. Keeping that trick means the two parsers stay in sync.
pub fn parse_hostedftp_line(
line : String,
now : @time.ZonedDateTime,
) -> Entry? raise FtpError {
guard line.length() >= 10 else { return None }
// The first field must be a valid permission string; otherwise this is not
// a hostedftp line and we let the caller fall through.
guard is_permission_field(line, 0) else { return None }
let patched = line[0:10].to_owned() + " 0 " + line[10:].to_owned()
parse_ls_line(patched, now)
}
///|
/// Whether the 10 (or 11, with an ACL `+`) bytes at `start` are a plausible
/// permission field.
fn is_permission_field(src : String, start : Int) -> Bool {
guard start + 10 <= src.length() else { return false }
let first = src.unsafe_get(start).to_int()
guard first == 100 || first == 45 || first == 108 else { return false }
guard start + 11 <= src.length() else { return true }
let marker = src.unsafe_get(start + 10).to_int()
marker == 43 || marker == 32
}
///|
/// The English month abbreviations accepted in `LIST` output, in calendar
/// order (`Jan` == 1).
pub let month_names : Array[String] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
]
///|
/// Parse a three letter month name case-insensitively, returning 1..12.
pub fn parse_month(name : String) -> Int? {
let lower = name.to_lower()
for i = 0; i < month_names.length(); i = i + 1 {
if month_names[i] == lower {
return Some(i + 1)
}
}
None
}
///|
/// Parse a strictly fixed-width decimal field. `width` is the expected number
/// of digits; anything shorter is padded from the left, anything longer makes
/// the whole date unrecognised.
fn parse_fixed_digits(src : String, start : Int, width : Int) -> Int? {
guard start >= 0 && start + width <= src.length() else { return None }
let mut value = 0
for i = start; i < start + width; i = i + 1 {
let code = src.unsafe_get(i).to_int()
guard code >= 48 && code <= 57 else { return None }
value = value * 10 + (code - 48)
}
Some(value)
}
///|
/// Parse a decimal field of 1..`max_width` digits starting at `start`,
/// returning the value and the position right after the last digit.
fn parse_digits(src : String, start : Int, max_width : Int) -> (Int, Int)? {
let mut i = start
let mut value = 0
let mut digits = 0
while i < src.length() && digits < max_width {
let code = src.unsafe_get(i).to_int()
if code < 48 || code > 57 {
break
}
value = value * 10 + (code - 48)
digits += 1
i += 1
}
if digits == 0 {
None
} else {
Some((value, i))
}
}
///|
/// Interpret the `MMM DD HH:MM` / `MMM DD YYYY` time field of a `LIST` line.
///
/// This is upstream's `setTime` including the **six month rule** from
/// `info ls` 10.1.6:
///
/// - when the field contains `:` there is no year, so the current year is
/// assumed; if the resulting instant is not before `now + 6 months`, the
/// year is decremented,
/// - when the field has no `:` the year must be exactly four digits, a
/// malformed year is an error (not a fallback),
/// - a missing time (`HH:MM`) defaults to midnight.
pub fn apply_list_time(
entry : Entry,
year : Int,
month : Int,
day : Int,
has_time : Bool,
hour : Int,
minute : Int,
now : @time.ZonedDateTime,
) -> Unit raise FtpError {
guard month >= 1 && month <= 12 else {
raise FtpError::UnsupportedListDate(field="\{month}")
}
let mut resolved_year = year
if has_time == false && year < 1000 {
// Missing year, e.g. the "MMM DD HH:MM" form is handled below; a numeric
// year field must be exactly 4 digits.
raise FtpError::UnsupportedListDate(field="\{year}")
}
if has_time {
// No year in the field: assume the current year, then apply the half-year
// rule. The whole comparison is done on the wall-clock fields of `now`
// and the candidate, which is what upstream does.
let candidate = new_datetime(
resolved_year,
month,
day,
hour,
minute,
now.zone(),
)
// Half-year rule: a candidate that is not earlier than `now` plus six
// months must belong to the previous year. The boundary is exact to the
// minute, hence `>=`, not `>`.
let now_plus_six_months = now.add_months(6) catch {
_ => raise FtpError::UnsupportedListDate(field="\{month}/\{day}")
}
if candidate.to_unix_second() >= now_plus_six_months.to_unix_second() {
resolved_year -= 1
}
}
guard day >= 1 &&
day <= 31 &&
hour >= 0 &&
hour <= 23 &&
minute >= 0 &&
minute <= 59 else {
raise FtpError::UnsupportedListDate(field="\{day} \{hour}:\{minute}")
}
let timestamp = new_datetime(
resolved_year,
month,
day,
hour,
minute,
now.zone(),
)
entry.set_time(timestamp)
}
///|
/// Build a `ZonedDateTime` in `zone`, raising `UnsupportedListDate` when the
/// calendar fields are not a valid date (e.g. Feb 30).
pub fn new_datetime(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
zone : @time.Zone,
) -> @time.ZonedDateTime raise FtpError {
@time.ZonedDateTime::of(year, month, day, hour~, minute~, second=0, zone~) catch {
_ => raise FtpError::UnsupportedListDate(field="\{year}-\{month}-\{day}")
}
}
///|
/// Parse the two year-dependent shapes used by the `LIST` time field, given
/// the `MMM DD ` prefix has already been consumed.
///
/// Returns `(year, month, day, has_time, hour, minute, next_index)`. `year` is
/// `0` when the field carried no year at all (the `HH:MM` shape), the caller
/// substitutes the current year.
pub fn parse_list_date_field(src : String, start : Int) -> ListDateField? {
// The month name is followed by one or more spaces before the day.
let day_start = skip_spaces(src, start)
let first = parse_digits(src, day_start, 2)
guard first is Some((day, after_day)) else { return None }
let i = skip_spaces(src, after_day)
guard i < src.length() else { return None }
// Shape "DD HH:MM": no year at all.
if is_digit(src, i) {
match parse_hh_mm(src, i) {
Some((hour, minute, next)) =>
return Some({ year: 0, day, has_time: true, hour, minute, next, })
None => ()
}
}
// Shape "DD YYYY".
let year = parse_digits(src, i, 4)
guard year is Some((y, after_year)) else { return None }
Some({ year: y, day, has_time: false, hour: 0, minute: 0, next: after_year, })
}
///|
/// The pieces of a `LIST` date field: `MMM` is consumed by the caller.
struct ListDateField {
/// The year, or `0` when the field carried none.
year : Int
/// Day of month.
day : Int
/// Whether the field used the `HH:MM` shape (and therefore has no year).
has_time : Bool
/// Hour, `0` when absent.
hour : Int
/// Minute, `0` when absent.
minute : Int
/// Index right after the field.
next : Int
}
///|
/// The year of a parsed date field, `0` when the field carried none.
pub fn ListDateField::year(self : ListDateField) -> Int {
self.year
}
///|
/// The day of month of a parsed date field.
pub fn ListDateField::day(self : ListDateField) -> Int {
self.day
}
///|
/// Whether the field used the yearless `HH:MM` shape.
pub fn ListDateField::has_time(self : ListDateField) -> Bool {
self.has_time
}
///|
/// The hour, `0` when the field carried none.
pub fn ListDateField::hour(self : ListDateField) -> Int {
self.hour
}
///|
/// The minute, `0` when the field carried none.
pub fn ListDateField::minute(self : ListDateField) -> Int {
self.minute
}
///|
/// The index right after the parsed date field.
pub fn ListDateField::next(self : ListDateField) -> Int {
self.next
}
///|
/// Offset of the first non-space byte at or after `start`.
fn skip_spaces(src : String, start : Int) -> Int {
let mut i = start
while i < src.length() && src.unsafe_get(i).to_int() == 32 {
i += 1
}
i
}
///|
/// Whether `src[i]` is an ASCII digit.
fn is_digit(src : String, i : Int) -> Bool {
guard i < src.length() else { return false }
let code = src.unsafe_get(i).to_int()
code >= 48 && code <= 57
}
///|
/// Parse a strict `HH:MM` field. Returns `None` when the field is not exactly
/// two digits, colon, two digits.
fn parse_hh_mm(src : String, start : Int) -> (Int, Int, Int)? {
guard start + 5 <= src.length() else { return None }
let hour = parse_fixed_digits(src, start, 2)
guard hour is Some(hour) else { return None }
guard src.unsafe_get(start + 2).to_int() == 58 else { return None }
let minute = parse_fixed_digits(src, start + 3, 2)
guard minute is Some(minute) else { return None }
Some((hour, minute, start + 5))
}
///|
/// Parse an `ls -l` time field: `MMM DD HH:MM` or `MMM DD YYYY`.
///
/// Returns the resolved time and the index right after the field.
pub fn parse_ls_time(
src : String,
start : Int,
now : @time.ZonedDateTime,
) -> (@time.ZonedDateTime, Int)? raise FtpError {
guard start + 3 <= src.length() else { return None }
let month = parse_month(src[start:start + 3].to_owned())
guard month is Some(month) else { return None }
let parsed = parse_list_date_field(src, start + 3)
guard parsed is Some(field) else { return None }
let resolved_year = if field.has_time {
probe_year(month, field.day, field.hour, field.minute, now)
} else {
field.year
}
let timestamp = new_datetime(
resolved_year,
month,
field.day,
field.hour,
field.minute,
now.zone(),
)
Some((timestamp, field.next))
}
///|
/// Apply the six month rule and return the year to use for a yearless
/// `MMM DD HH:MM` field.
fn probe_year(
month : Int,
day : Int,
hour : Int,
minute : Int,
now : @time.ZonedDateTime,
) -> Int {
let candidate = @time.ZonedDateTime::of(
now.year(),
month,
day,
hour~,
minute~,
second=0,
zone=now.zone(),
) catch {
_ => return now.year()
}
let now_plus_six_months = now.add_months(6) catch { _ => return now.year() }
// The candidate is "in the future beyond the six month window" when it is
// not earlier than now + 6 months; such a timestamp must be from last year.
if candidate.to_unix_second() >= now_plus_six_months.to_unix_second() {
now.year() - 1
} else {
now.year()
}
}
///|
/// A whitespace separated field scanner, a faithful port of upstream
/// `scanner.go`.
///
/// The position semantics are *not* intuitive and upstream has a dedicated
/// test for them, so they are spelled out here:
///
/// ```text
/// new Scanner("foo bar x y")
/// .next() -> "foo", position stops *after* the run of spaces
/// .remaining() -> " bar x y" // the leading space is kept
/// .next() -> "bar"
/// .remaining() -> "x y"
/// ```
///
/// In other words `next()` consumes the field **and exactly one following
/// space**. Everything after that stays in `remaining()`.
pub struct Scanner {
src : String
/// Byte offset of the next character to read.
mut offset : Int
}
///|
/// Create a scanner over `src`.
pub fn Scanner::new(src : String) -> Scanner {
{ src, offset: 0, }
}
///|
/// Whether all input has been consumed.
pub fn Scanner::at_end(self : Scanner) -> Bool {
self.offset >= self.src.length()
}
///|
/// The not-yet-consumed tail of the input.
pub fn Scanner::remaining(self : Scanner) -> String {
guard self.offset <= self.src.length() else { return "" }
self.src[self.offset:].to_owned()
}
///|
/// Consume and return the next whitespace separated field.
///
/// Returns `""` at end of input. As in upstream, an empty field is only
/// produced at the end — runs of spaces never yield empty fields.
pub fn Scanner::next(self : Scanner) -> String {
let len = self.src.length()
// Skip the separator left over from the previous call.
while self.offset < len &&
is_space_byte(self.src.unsafe_get(self.offset).to_int()) {
self.offset += 1
}
guard self.offset < len else { return "" }
let start = self.offset
while self.offset < len &&
is_space_byte(self.src.unsafe_get(self.offset).to_int()) == false {
self.offset += 1
}
let field = self.src[start:self.offset].to_owned()
// Consume exactly one trailing space, as upstream does.
if self.offset < len &&
is_space_byte(self.src.unsafe_get(self.offset).to_int()) {
self.offset += 1
}
field
}
///|
/// Read up to `count` consecutive fields. Stops early (without raising) when
/// the input is exhausted, exactly like upstream `nextFields`.
pub fn Scanner::next_fields(self : Scanner, count : Int) -> Array[String] {
let fields : Array[String] = []
for i = 0; i < count; i = i + 1 {
let field = self.next()
if field == "" {
break
}
fields.push(field)
}
fields
}
///|
/// Whether `byte` is one of the ASCII whitespace characters used by FTP
/// directory listings (` `, `\t`, `\r`, `\n`).
fn is_space_byte(byte : Int) -> Bool {
byte == 32 || byte == 9 || byte == 13 || byte == 10
}