ajhahn.de
← FlashOS
Flash 212 lines
// flibc tab-completion core — the discovery half of FlashOS's shell-first
// navigation.
//
// fsh's line editor calls in here when the user presses TAB. The pure pieces
// live here and are host-tested:
//   * parse(line)          — decide what is being completed: the command (the
//                            first token) or a path argument (a later token),
//                            and split a path token into dir + basename prefix.
//   * hasPrefix / commonPrefixLen — the string folds the driver uses to filter
//                            candidates and shrink them to a shared extension.
// The candidate gathering itself (a sys_readdir walk over /bin or the path's
// directory, plus fsh's injected built-in names) is the SVC-driven half and
// lives in readline.flash behind its has_driver gate, so this file stays pure,
// allocator-free, and target-agnostic.

/// What a TAB is completing.
pub const Kind = enum {
    command, // the first token — match against /bin + the shell's built-ins
    path, // a later token — match against entries of `dir`
}

/// A parsed completion request. `dir` and `prefix` are slices into the caller's
/// line buffer (or static literals). For a command, `dir` is "" (the driver
/// searches /bin). For a path, `dir` is the directory portion — "" means the
/// cwd, "/" the root, "/bin" an absolute dir — and `prefix` the partial
/// basename to extend.
pub const Context = struct {
    kind Kind,
    dir []u8,
    prefix []u8,
}

/// Parse the completion context from the current line. The token under
/// completion is the last whitespace-delimited run; if no earlier token
/// precedes it, it is a command, otherwise a path.
pub fn parse(line []u8) Context {
    // Start of the last token = one past the last space/tab.
    var tok_start usize = 0
    var i usize = 0
    while i < line.len {
        if line[i] == ' ' || line[i] == '\t' {
            tok_start = i + 1
        }
        i += 1
    }
    // Is there a non-space byte before the token? (an earlier token exists)
    var earlier = false
    var j usize = 0
    while j < tok_start {
        if line[j] != ' ' && line[j] != '\t' {
            earlier = true
            break
        }
        j += 1
    }
    const token = line[tok_start..]

    if !earlier {
        return .{ .kind = .command, .dir = "", .prefix = token }
    }

    // Path token: split at the last '/'.
    var slash ?usize = null
    var k usize = 0
    while k < token.len {
        if token[k] == '/' {
            slash = k
        }
        k += 1
    }
    if slash |s| {
        const dir []u8 = if (s == 0) "/" else token[0..s]
        return .{ .kind = .path, .dir = dir, .prefix = token[s + 1 ..] }
    }
    return .{ .kind = .path, .dir = "", .prefix = token }
}

/// True when `name` starts with `prefix`.
pub fn hasPrefix(name []u8, prefix []u8) bool {
    if name.len < prefix.len {
        return false
    }
    var i usize = 0
    while i < prefix.len {
        if name[i] != prefix[i] {
            return false
        }
        i += 1
    }
    return true
}

/// Length of the longest common prefix of `a` and `b`.
pub fn commonPrefixLen(a []u8, b []u8) usize {
    const m = #min(a.len, b.len)
    var i usize = 0
    while i < m && a[i] == b[i] {
        i += 1
    }
    return i
}

/// What a TAB press did to the line — the driver's branch point for double-TAB
/// listing. `count` candidates share the longest common prefix `best_len`; the
/// user has already typed `prefix_len` bytes of the token.
pub const TabClass = enum {
    /// The line grew: either a unique match (which also gets a trailing
    /// separator) or the common prefix extended past what was typed. Reset the
    /// double-TAB streak.
    progressed,
    /// Two or more candidates already at their common prefix — nothing left to
    /// insert. A second consecutive `stuck` TAB lists them.
    stuck,
    /// Nothing matched; the TAB is inert.
    empty,
}

/// Classify a completion attempt from its candidate tally. A unique match always
/// progresses (the driver appends a ' ' / '/' even when the typed token already
/// equals the name); multiple candidates progress only while their common prefix
/// runs past the typed prefix, otherwise they are `stuck` and a redraw-listing is
/// the only forward move.
pub fn classify(count usize, best_len usize, prefix_len usize) TabClass {
    if count == 0 {
        return .empty
    }
    if count == 1 {
        return .progressed
    }
    return if (best_len > prefix_len) .progressed else .stuck
}

// ---- host tests ------------------------------------------------------------

const std = #import("std")
const testing = std.testing

test "parse: a first token is a command" {
    const c = parse("ls")
    try testing.expectEqual(Kind.command, c.kind)
    try testing.expectEqualStrings("ls", c.prefix)
    try testing.expectEqualStrings("", c.dir)
}

test "parse: an empty line is an empty command" {
    const c = parse("")
    try testing.expectEqual(Kind.command, c.kind)
    try testing.expectEqualStrings("", c.prefix)
}

test "parse: a token after a space is a path in the cwd" {
    const c = parse("cat fo")
    try testing.expectEqual(Kind.path, c.kind)
    try testing.expectEqualStrings("", c.dir)
    try testing.expectEqualStrings("fo", c.prefix)
}

test "parse: an absolute path token splits dir and prefix" {
    const c = parse("cat /bin/l")
    try testing.expectEqual(Kind.path, c.kind)
    try testing.expectEqualStrings("/bin", c.dir)
    try testing.expectEqualStrings("l", c.prefix)
}

test "parse: a root-level path token keeps dir as /" {
    const c = parse("ls /b")
    try testing.expectEqual(Kind.path, c.kind)
    try testing.expectEqualStrings("/", c.dir)
    try testing.expectEqualStrings("b", c.prefix)
}

test "parse: a trailing slash yields an empty prefix" {
    const c = parse("ls /bin/")
    try testing.expectEqual(Kind.path, c.kind)
    try testing.expectEqualStrings("/bin", c.dir)
    try testing.expectEqualStrings("", c.prefix)
}

test "hasPrefix" {
    try testing.expect(hasPrefix("login", "lo"))
    try testing.expect(hasPrefix("ls", "ls"))
    try testing.expect(!hasPrefix("a", "ab"))
    try testing.expect(hasPrefix("anything", ""))
}

test "commonPrefixLen" {
    try testing.expectEqual(#as(usize, 3), commonPrefixLen("login", "logout")) // "log"
    try testing.expectEqual(#as(usize, 0), commonPrefixLen("a", "b"))
    try testing.expectEqual(#as(usize, 3), commonPrefixLen("cat", "cat"))
}

test "classify: no candidates is empty" {
    try testing.expectEqual(TabClass.empty, classify(0, 0, 3))
}

test "classify: a unique match always progresses" {
    // Extends ("l" -> "ls") and exact ("ls" with only "ls" matching) both
    // progress — the exact case still earns its trailing separator.
    try testing.expectEqual(TabClass.progressed, classify(1, 2, 1))
    try testing.expectEqual(TabClass.progressed, classify(1, 2, 2))
}

test "classify: ambiguous but still extendable progresses" {
    // Typed "l", three candidates share "lo": the common prefix runs ahead.
    try testing.expectEqual(TabClass.progressed, classify(3, 2, 1))
}

test "classify: ambiguous at the common prefix is stuck" {
    // Typed "lo", three candidates share exactly "lo": nothing left to insert.
    try testing.expectEqual(TabClass.stuck, classify(3, 2, 2))
}