~leon_plickat/nfm

nfm: [WIP] Rewrite tab completion v3 PROPOSED

Hugo Machet: 1
 [WIP] Rewrite tab completion

 4 files changed, 393 insertions(+), 104 deletions(-)
#915375 alpine.yml success
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~leon_plickat/nfm/patches/37905/mbox | git am -3
Learn more about email & git

[PATCH nfm v3] [WIP] Rewrite tab completion Export this patch

---
Rebased against master
Update to new allocator convention

 src/Completion.zig         | 145 +++++++++++++++++++++++++++++++++++++
 src/Context.zig            |  90 +----------------------
 src/EditableUTF8String.zig | 141 ++++++++++++++++++++++++++++++++----
 src/mode.zig               | 121 +++++++++++++++++++++++++++++--
 4 files changed, 393 insertions(+), 104 deletions(-)
 create mode 100644 src/Completion.zig

diff --git a/src/Completion.zig b/src/Completion.zig
new file mode 100644
index 000000000000..412a5e272055
--- /dev/null
+++ b/src/Completion.zig
@@ -0,0 +1,145 @@
// This file is part of nfm, the neat file manager.
//
// Copyright © 2022 Hugo Machet
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published
// by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

const std = @import("std");
const ascii = std.ascii;
const debug = std.debug;
const fs = std.fs;
const heap = std.heap;
const mem = std.mem;
const unicode = std.unicode;
const log = std.log.scoped(.completion);

const Self = @This();

const Dir = @import("Dir.zig");
const File = @import("File.zig");

const context = &@import("nfm.zig").context;

pub const DirsList = struct {
    dirs: std.TailQueue(Dir) = .{},

    pub fn deinit(self: *DirsList) void {
        while (self.dirs.pop()) |node| {
            node.data.deinit();
            context.alloc.destroy(node);
        }
    }

    /// Add a new dir to DirsList.dirs if needed
    pub fn addDir(self: *DirsList, path: []const u8, fname: []const u8) !void {
        // TODO: More reliable way to compare them than using name?
        if (self.dirs.len > 0) {
            const last_name = fs.path.basename(self.dirs.last.?.data.name);
            if (mem.eql(u8, fname, last_name)) return;
        }

        const abspath = try fs.path.join(context.alloc, &[_][]const u8{ path, "/", fname });
        errdefer context.alloc.free(abspath);
        log.debug("completion path: {s}", .{abspath});
        debug.assert(fs.path.isAbsolute(abspath));
        var dir = try fs.openIterableDirAbsolute(abspath, .{});
        defer dir.close();

        var node = try context.alloc.create(std.TailQueue(Dir).Node);
        try node.data.init(&context.dirmap, dir, abspath);
        self.dirs.append(node);
    }

    pub fn maybeRemoveLast(self: *DirsList) void {
        if (self.dirs.len == 0) return;
        const last = self.dirs.pop() orelse unreachable;
        last.data.deinit();
        context.alloc.destroy(last);
    }
};

arena: heap.ArenaAllocator,
completions: std.TailQueue(*File) = .{},
max_name_len: usize = 0,

/// Amount of codepoints in the compare token. Used for offsetting the menu.
compare_len: usize = 0,

pub fn newFromDir(dir: *Dir) !Self {
    var ret = Self{
        .arena = heap.ArenaAllocator.init(context.alloc),
    };
    errdefer ret.arena.deinit();
    const arena = ret.arena.allocator();
    for (dir.files.items) |*file| {
        const node = try arena.create(std.TailQueue(*File).Node);
        node.data = file;
        ret.completions.append(node);
    }
    return ret;
}

pub fn deinit(self: *const Self) void {
    self.arena.deinit();
}

pub fn compare(self: *Self, str: []const u8) usize {
    var it = self.completions.first;
    while (it) |node| : (it = node.next) {
        if (!ascii.startsWithIgnoreCase(node.data.*.name, str)) {
            self.completions.remove(node);
        }
    }
    self.setMaxStringLen();

    return self.completions.len;
}

fn setMaxStringLen(self: *Self) void {
    var len: usize = 0;
    var it = self.completions.first;
    while (it) |node| : (it = node.next) {
        const l = unicode.utf8CountCodepoints(node.data.*.name) catch return;
        if (l > len) len = l;
    }
    self.max_name_len = len;
}

pub fn getFirst(self: *Self) ?*File {
    if (self.completions.len == 0) return null;
    return self.completions.first.?.data;
}

pub fn getCommonStart(self: *Self) ?[]const u8 {
    if (self.completions.len == 0) return null;

    var common: []const u8 = self.completions.first.?.data.name;
    var it = self.completions.first;
    while (it) |node| : (it = node.next) {
        common = commonStart(common, node.data.*.name);
    }
    return common;
}

/// Compare two strings and return the common beginning part.
/// Case insensitive.
fn commonStart(a: []const u8, b: []const u8) []const u8 {
    const len: usize = if (a.len < b.len) a.len else b.len;

    var offset: usize = 0;
    while (offset < len) : (offset += 1) {
        if (ascii.toLower(a[offset]) != ascii.toLower(b[offset])) break;
    }

    return a[0..offset];
}
diff --git a/src/Context.zig b/src/Context.zig
index 23fd793909c6..6f05cd5b7372 100644
--- a/src/Context.zig
+++ b/src/Context.zig
@@ -528,21 +528,17 @@ fn handleSpoonInputUserInput(self: *Self, in: spoon.Input) !void {
                try self.handleReturnUserInput();
                return;
            },
            '\t' => {
                var token = buffer.getCompletionToken() orelse return;
                if (try self.completion(token)) |compl| {
                    buffer.killCompletionToken();
                    try buffer.insertQuotedSliceAtCursor(compl);
                    defer self.alloc.free(compl);
                }
            },
            '\t' => try self.ui_mode.user_input.updateCompletion(.force),
            127 => { // Backspace.
                if (self.ui_mode.user_input.history_index != null) return;
                buffer.deleteOneBackward();
                self.ui_mode.user_input.maybeGoPrevCmpDir();
                try self.ui_mode.user_input.updateCompletion(.passive);
            },
            else => {
                if (self.ui_mode.user_input.history_index != null) return;
                try buffer.insertCodepointAtCursor(in.content.codepoint);
                try self.ui_mode.user_input.updateCompletion(.passive);
            },
        }
    }
@@ -713,81 +709,3 @@ fn mouseClickPathFromTitle(self: *Self, _x: usize) !void {
        }
    }
}

/// Compare current input with files/directories names. Complete the name if
/// there is only one match else only complete the common part.
/// Case insensitive.
fn completion(self: *Self, input: []const u8) !?[]const u8 {
    var found: std.ArrayListUnmanaged(*File) = .{};
    defer found.deinit(self.alloc);

    const fdv = self.dirmap.focusedDirView() orelse return null;
    for (fdv.dir.files.items) |*file| {
        if (file.name.len <= input.len) continue;
        if (ascii.startsWithIgnoreCase(file.name, input)) {
            try found.append(self.alloc, file);
        }
    }

    if (found.items.len == 0) {
        self.completion_count = null;
        return null;
    } else if (found.items.len == 1) {
        self.completion_count = null;
        const escaped = try self.escapeSpecialChar(found.items[0].name);
        return escaped;
    } else {
        self.completion_count = found.items.len;
        var common: []const u8 = found.items[0].name;
        var i: usize = 1;
        while (i < found.items.len) : (i += 1) {
            common = commonStart(common, found.items[i].name);
        }
        const escaped = try self.escapeSpecialChar(common);
        return escaped;
    }
}

/// Compare two strings and return the common beginning part.
/// Case insensitive.
fn commonStart(a: []const u8, b: []const u8) []const u8 {
    const len: usize = if (a.len < b.len) a.len else b.len;

    var offset: usize = 0;
    while (offset < len) : (offset += 1) {
        if (ascii.toLower(a[offset]) != ascii.toLower(b[offset])) break;
    }

    return a[0..offset];
}

/// Escape special characters in file names.
///   foo bar -> foo\ bar
///   foo\ bar -> foo\\\ bar
///   foo"bar -> foo\"bar
///   foo'bar -> foo"'"bar
fn escapeSpecialChar(self: *Self, s: []const u8) ![]const u8 {
    var ret: std.ArrayListUnmanaged(u8) = .{};

    var i: usize = 0;
    while (i < s.len) : (i += 1) {
        if (s[i] == '\\') {
            if (s[i + 1] == ' ') {
                try ret.appendSlice(self.alloc, "\\\\\\ ");
                i += 1;
            } else {
                try ret.append(self.alloc, s[i]);
            }
        } else if (s[i] == ' ') {
            try ret.appendSlice(self.alloc, "\\ ");
        } else if (s[i] == '"') {
            try ret.appendSlice(self.alloc, "\\\"");
        } else if (s[i] == '\'') {
            try ret.appendSlice(self.alloc, "\"'\"");
        } else {
            try ret.append(self.alloc, s[i]);
        }
    }

    return ret.toOwnedSlice(self.alloc);
}
diff --git a/src/EditableUTF8String.zig b/src/EditableUTF8String.zig
index 12a365716e0a..edb9e7b7c70f 100644
--- a/src/EditableUTF8String.zig
+++ b/src/EditableUTF8String.zig
@@ -874,18 +874,75 @@ test "EditableUTF8String: killToEndOfLine()" {
    }
}

pub const CompletionToken = struct {
    const Kind = enum { cwd, absolute };
    kind: Kind = .cwd,
    bytes: ?[]const u8 = null,
    cursor: ?Cursor = null,
};

// TODO a line may be 'word "word %c word', in which case the completion token should be 'word '
// TODO escaped spaces
pub fn getCompletionToken(self: *Self) ?[]const u8 {
    if (self.cursor.byte == 0) return null;
    if (ascii.isWhitespace(self.buffer.items[self.cursor.byte - 1])) return null;
    const c = self.prevWordStart(self.cursor) orelse return null;
    return self.buffer.items[c.byte..self.cursor.byte];
pub fn getCompletionToken(self: *Self) CompletionToken {
    var tok = CompletionToken{};
    if (self.cursor.byte == 0) return tok;
    if (ascii.isWhitespace(self.buffer.items[self.cursor.byte - 1])) {
        return tok;
    } else if (self.buffer.items[self.cursor.byte - 1] == '/') {
        tok.kind = .absolute;
        return tok;
    }
    tok = self.completionStart(self.cursor);
    if (tok.cursor) |c| {
        tok.bytes = self.buffer.items[c.byte..self.cursor.byte];
    }
    return tok;
}

// TODO update this when improving getCompletionToken()
pub fn killCompletionToken(self: *Self) void {
    self.killToPrevWordStart();
    const tok = self.completionStart(self.cursor);
    if (tok.cursor) |c| {
        var to_remove = self.cursor.byte - c.byte; // TODO this is inefficient
        while (to_remove > 0) : (to_remove -= 1) _ = self.buffer.orderedRemove(c.byte);
        self.codepoint_len -= self.cursor.codepoint - c.codepoint;
        self.cursor = c;
    } else return;
}

/// Find the start of the word to complete in the buffer.
/// Delimiter: whitespace /
fn completionStart(self: *Self, cursor: Cursor) CompletionToken {
    var tok = CompletionToken{};
    tok.cursor = cursor;

    // The cursor may be at the end of the line or we may already be at a completion start.
    if (tok.cursor.?.byte == self.buffer.items.len or !ascii.isWhitespace(self.buffer.items[tok.cursor.?.byte]) or
        self.buffer.items[tok.cursor.?.byte] != '/')
    {
        tok.cursor = self.cursorLeftOf(tok.cursor.?) orelse return tok;
    }

    // Skip delimiter.
    while (self.cursorLeftOf(tok.cursor.?)) |c| {
        if (!ascii.isWhitespace(self.buffer.items[tok.cursor.?.byte]) or self.buffer.items[tok.cursor.?.byte] != '/') {
            break;
        }
        tok.cursor = c;
    } else return tok;

    // Skip non delimiter.
    while (self.cursorLeftOf(tok.cursor.?)) |c| {
        if (ascii.isWhitespace(self.buffer.items[c.byte])) {
            break;
        } else if (self.buffer.items[c.byte] == '/') {
            tok.kind = .absolute;
            break;
        }
        tok.cursor = c;
    }

    return tok;
}

test "EditableUTF8String: getCompletionToken()" {
@@ -895,7 +952,7 @@ test "EditableUTF8String: getCompletionToken()" {
        a.moveCursorOneBackward();
        a.moveCursorOneForward();
        const token = a.getCompletionToken();
        try std.testing.expect(token == null);
        try std.testing.expect(token.bytes == null);
        a.deinit();
    }

@@ -903,8 +960,8 @@ test "EditableUTF8String: getCompletionToken()" {
        const str = "word   word";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token != null);
        try std.testing.expectEqualSlices(u8, "word", token.?);
        try std.testing.expect(token.bytes != null);
        try std.testing.expectEqualSlices(u8, "word", token.bytes.?);
        a.deinit();
    }

@@ -913,8 +970,8 @@ test "EditableUTF8String: getCompletionToken()" {
        var a = try Self.from(str);
        a.moveCursorOneBackward();
        const token = a.getCompletionToken();
        try std.testing.expect(token != null);
        try std.testing.expectEqualSlices(u8, "öäü", token.?);
        try std.testing.expect(token.bytes != null);
        try std.testing.expectEqualSlices(u8, "öäü", token.bytes.?);
        a.deinit();
    }

@@ -922,7 +979,7 @@ test "EditableUTF8String: getCompletionToken()" {
        const str = "word   ";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token == null);
        try std.testing.expect(token.bytes == null);
        a.deinit();
    }

@@ -931,7 +988,65 @@ test "EditableUTF8String: getCompletionToken()" {
        var a = try Self.from(str);
        a.moveCursorOneBackward();
        const token = a.getCompletionToken();
        try std.testing.expect(token == null);
        try std.testing.expect(token.bytes == null);
        a.deinit();
    }

    {
        const str = "/word";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.bytes != null);
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expectEqualSlices(u8, "word", token.bytes.?);
        a.deinit();
    }

    {
        const str = "word/foo";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.bytes != null);
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expectEqualSlices(u8, "foo", token.bytes.?);
        a.deinit();
    }

    {
        const str = "word/";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expect(token.bytes == null);
        a.deinit();
    }

    {
        const str = "/word";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.bytes != null);
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expectEqualSlices(u8, "word", token.bytes.?);
        a.deinit();
    }

    {
        const str = "word/foo";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.bytes != null);
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expectEqualSlices(u8, "foo", token.bytes.?);
        a.deinit();
    }

    {
        const str = "word/";
        var a = try Self.from(str);
        const token = a.getCompletionToken();
        try std.testing.expect(token.kind == .absolute);
        try std.testing.expect(token.bytes == null);
        a.deinit();
    }
}
diff --git a/src/mode.zig b/src/mode.zig
index 55154dc07e37..7c9a08d3d8e3 100644
--- a/src/mode.zig
+++ b/src/mode.zig
@@ -15,14 +15,18 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.

const spoon = @import("spoon");
const build_options = @import("build_options");
const std = @import("std");
const ascii = std.ascii;
const debug = std.debug;
const fs = std.fs;
const math = std.math;
const mem = std.mem;
const logger = std.log.scoped(.mode);
const log = std.log.scoped(.mode);

const EditableUTF8String = @import("EditableUTF8String.zig");
const Dir = @import("Dir.zig");
const File = @import("File.zig");
const Completion = @import("Completion.zig");

const context = &@import("nfm.zig").context;

@@ -54,21 +58,125 @@ pub const UIMode = union(enum) {

    /// User input mode: Title bar is mini-editor for user input.
    user_input: struct {
        const UpdateCompletionMode = enum { force, passive };

        const UserInput = @This();
        operation: UserInputOperation,
        buffer: EditableUTF8String,
        history_index: ?usize,
        completion: ?Completion = null,
        cmp_dirs: Completion.DirsList,

        /// Wrapper around UserInput.complete() to handle files errors.
        pub fn updateCompletion(self: *UserInput, mode: UpdateCompletionMode) !void {
            self.complete(mode) catch |err| switch (err) {
                error.AccessDenied => return,
                else => return err,
            };
        }

        fn complete(self: *UserInput, mode: UpdateCompletionMode) !void {
            const token = self.buffer.getCompletionToken();
            // log.debug("token kind: {s}, bytes: {?s}", .{ @tagName(token.kind), token.bytes });
            if (self.completion == null) {
                if (self.cmp_dirs.dirs.len > 0) {
                    self.completion = try Completion.newFromDir(&self.cmp_dirs.dirs.last.?.data);
                } else {
                    switch (token.kind) {
                        .cwd => {
                            if (context.dirmap.focusedDirView()) |fdv| {
                                self.completion = try Completion.newFromDir(fdv.dir);
                            }
                        },
                        .absolute => {
                            try self.cmp_dirs.addDir("/", "");
                            self.completion = try Completion.newFromDir(&self.cmp_dirs.dirs.last.?.data);
                        },
                    }
                }
            }

            if (token.bytes) |b| {
                self.completion.?.compare_len = b.len;
                switch (mode) {
                    .force => try self.forceCompletion(b),
                    .passive => try self.passiveCompletion(b),
                }
            }
        }

        pub fn abortCompletion(self: *UserInput) void {
            if (self.completion) |cmp| {
                cmp.deinit();
                self.completion = null;
            }
        }

        /// Remove the last dir from the cmp_dirs list if we are at the begining
        /// of the token.
        // TODO: Find a better way to know when we need to remove a child dir
        // For example, having 'foo/bar', removing bar will work correctly but then
        // adding 'b' and deleting 'b' will remove 'foo' from childs
        pub fn maybeGoPrevCmpDir(self: *UserInput) void {
            const token = self.buffer.getCompletionToken();
            if (token.bytes == null) self.cmp_dirs.maybeRemoveLast();
            self.abortCompletion();
        }

        /// Update the completion on 'Tab' and insert the suggestion in the input buffer.
        fn forceCompletion(self: *UserInput, bytes: []const u8) !void {
            switch (context.ui_mode.user_input.completion.?.compare(bytes)) {
                0 => self.abortCompletion(),
                1 => {
                    self.buffer.killCompletionToken();
                    const compl = self.completion.?.getFirst() orelse unreachable;
                    try self.buffer.insertQuotedSliceAtCursor(compl.name);
                    if (compl.kind == .Directory) try self.cmp_dirs.addDir(compl.dir.name, compl.name);
                    self.abortCompletion();
                },
                else => {
                    self.buffer.killCompletionToken();
                    const compl = self.completion.?.getCommonStart() orelse unreachable;
                    try self.buffer.insertQuotedSliceAtCursor(compl);
                    context.completion_count = self.completion.?.completions.len;
                    self.abortCompletion();
                },
            }
        }

        /// Only update the count of suggestions without inserting anything in
        /// the input buffer.
        // TODO: Diplay the suggestions
        fn passiveCompletion(self: *UserInput, bytes: []const u8) !void {
            switch (context.ui_mode.user_input.completion.?.compare(bytes)) {
                0 => self.abortCompletion(),
                1 => {
                    const compl = self.completion.?.getFirst() orelse unreachable;
                    if (compl.kind == .Directory and mem.eql(u8, bytes, compl.name)) {
                        try self.cmp_dirs.addDir(compl.dir.name, compl.name);
                    }
                    context.completion_count = self.completion.?.completions.len;
                },
                else => {
                    // TODO: Should use the full file name instead of the
                    // common start to use in a widget.
                    _ = self.completion.?.getCommonStart() orelse unreachable;
                    context.completion_count = self.completion.?.completions.len;
                    self.abortCompletion();
                },
            }
        }
    },

    pub fn setNormal(self: *Self) void {
        if (self.* == .normal) return;
        logger.debug("normal mode", .{});
        log.debug("normal mode", .{});
        self.reset();
        self.* = .normal;
    }

    pub fn setMessage(self: *Self, comptime level: MessageLevel, comptime message: []const u8) void {
        logger.debug("message mode: {}, '{s}'", .{ level, message });
        log.debug("message mode: {}, '{s}'", .{ level, message });
        self.reset();
        self.* = .{ .message = .{
            .message = message,
@@ -77,12 +185,13 @@ pub const UIMode = union(enum) {
    }

    pub fn setUserInput(self: *Self, operation: UserInputOperation) !void {
        logger.debug("user input mode mode: {}", .{operation});
        log.debug("user input mode mode: {}", .{operation});
        self.reset();
        self.* = .{ .user_input = .{
            .operation = operation,
            .buffer = try EditableUTF8String.newWithCapacity(1024),
            .history_index = null,
            .cmp_dirs = .{},
        } };
    }

@@ -90,6 +199,8 @@ pub const UIMode = union(enum) {
        context.ui.title_dirty = true;
        if (self.* == .user_input) {
            self.user_input.buffer.deinit();
            if (self.user_input.completion) |cmp| cmp.deinit();
            self.user_input.cmp_dirs.deinit();
        }
    }
};
-- 
2.39.0
nfm/patches/alpine.yml: SUCCESS in 49s

[[WIP] Rewrite tab completion][0] v3 from [Hugo Machet][1]

[0]: https://lists.sr.ht/~leon_plickat/nfm/patches/37905
[1]: mailto:mail@hmachet.com

✓ #915375 SUCCESS nfm/patches/alpine.yml https://builds.sr.ht/~leon_plickat/job/915375