// 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)) }