Issue with Mutliple Completion

I’m stuck on Stage #WH6.

I’ve tried saving the previous tab calls prefix and results but the state doesn’t seem to be carrying over.

Here are my logs:

remote: [tester::#WH6] Running ./your_program.sh
remote: [tester::#WH6] ✓ Received prompt ($ )
remote: [tester::#WH6] Typed "xyz_"
remote: [your-program] $ xyz_
remote: [tester::#WH6] ✓ Prompt line matches "$ xyz_"
remote: [tester::#WH6] Pressed "<TAB>" (expecting bell to ring)
remote: [tester::#WH6] Pressed "<TAB>" (expecting autocomplete to "xyz_baz  xyz_foo  xyz_quz")
remote: [tester::#WH6] ✓ Received bell
remote: [tester::#WH6] Output does not match expected value.
remote: [tester::#WH6] Expected: "xyz_baz  xyz_foo  xyz_quz"
remote: [tester::#WH6] Received: ""
remote: [tester::#WH6] Assertion failed.
remote: [tester::#WH6] Test failed

And here’s a snippet of my code:

package main

import (
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

type shellCompleter struct{
	lastTabPrefix string
    isFirstTab   bool
	lastCandidates []string
}

func (c *shellCompleter) Do(line []rune, pos int) (newLine [][]rune, length int) {
    lineStr := string(line[:pos])
    words := strings.Fields(lineStr)

    prefix := ""
    if len(words) > 0 && !strings.HasSuffix(lineStr, " ") {
        prefix = words[len(words)-1] 
    }

    if len(words) == 0 || (len(words) == 1 && !strings.HasSuffix(lineStr, " ")) {
		if prefix != c.lastTabPrefix {
			c.lastTabPrefix = prefix
			c.isFirstTab = true
			c.lastCandidates = getMatchingCommands(prefix)
		}
        candidates := c.lastCandidates
		sort.Strings(candidates)

        if len(candidates) > 1 {
			if c.isFirstTab {
				c.isFirstTab = false
				return [][]rune{[]rune("\a")}, 0
			} else {
				c.isFirstTab = true
				fmt.Print("\n")
				fmt.Print(strings.Join(candidates, "  "))
				fmt.Print("\n")
				return [][]rune{[]rune(prefix)}, len(prefix)
			}
		}

        if len(candidates) == 1 {
            return [][]rune{[]rune(candidates[0][len(prefix):] + " ")}, len(prefix)
        }
    }

    wordCount := len(words)

    if wordCount == 0 || (wordCount == 1 && !strings.HasSuffix(lineStr, " ")) {
        return completeCommand(words)
    } else {
        return completeArguement(lineStr, words)
    }
}


func completeCommand(words []string) ([][]rune, int) {
	prefix := ""
	if len(words) == 1{
		prefix = words[0]
	}

	candidates := []string{}

	for _, cmd := range builtinCommands {
		if strings.HasPrefix(cmd, prefix) {
			candidates = append(candidates, cmd)
		}
	}

	pathEnv := os.Getenv("PATH")
	paths := strings.Split(pathEnv, string(os.PathListSeparator))

	seen := make(map[string] bool)
	for _, candidate := range candidates {
		seen[candidate] = true
	}

	for _, dir := range paths {
		entries, err := os.ReadDir(dir)
		if err != nil {
			continue
		}

		for _, entry := range entries {
			name := entry.Name()
			if strings.HasPrefix(name, prefix) && !seen[name] {
				info, err := entry.Info()
				if err == nil && isExecutable(info.Mode()) {
					candidates = append(candidates, name)
					seen[name] = true
				}
			}
		}
	}

	if len(candidates) == 0 && prefix != "" {
		return [][]rune{[]rune("\a")}, 0
	}
	
	if len(candidates) == 0 {
		return nil, 0
	}

	return formatCompletionResults(prefix, candidates)
}

func completeArguement(lineStr string, words []string) ([][]rune, int) {

	partial := ""
	if strings.HasSuffix(lineStr, " ") {
		partial = ""
	} else {
		partial = words[len(words) - 1]
	}

	searchDir := "." 
	partialBase := filepath.Base(partial)

	if filepath.IsAbs(partial) {
		searchDir = filepath.Dir(partial)
	} else if partial != partialBase {
		searchDir = filepath.Dir(partial)
	}

	if strings.HasPrefix(partial, "~/") {
		home, err := os.UserHomeDir()
		if err == nil {
			partial = filepath.Join(home, partial[2:])
			searchDir = filepath.Dir(partial)
			partialBase = filepath.Base(partial)
		}
	}

	entries, err := os.ReadDir(searchDir)
	if err != nil {
		return nil, 0
	}

	candidates := []string{}
	for _, entry := range entries {
		name := entry.Name()
		if strings.HasPrefix(name, partialBase) {
			fullPath := filepath.Join(searchDir, name)

			if entry.IsDir() {
				fullPath += string(os.PathSeparator)
			}

			if filepath.IsAbs(partial) || partial != partialBase {
				candidates = append(candidates, fullPath[len(searchDir) - 1:])
			} else {
				candidates = append(candidates, name)
			}
		}
	}

	if len(candidates) == 0 {
		return nil, 0
	}	

	prefix := partial
	return formatCompletionResults(prefix, candidates)

}

func formatCompletionResults(prefix string, candidates []string) ([][]rune, int) {
	
	if len(candidates) == 1 {
		
		completion := candidates[0][len(prefix):]
		if !strings.HasSuffix(candidates[0], string(os.PathSeparator)) {
			completion += " "
		}
		return [][]rune{[]rune(completion)}, len(prefix)
	} else {	
		completions := make([][]rune, len(candidates))
		for i, candidate := range candidates {
			completions[i] = []rune(candidate[len(prefix):])
		}
		return completions, len(prefix)
	}
}

func getMatchingCommands(prefix string) []string {
	var candidates []string

	for _, cmd := range builtinCommands {
		if strings.HasPrefix(cmd, prefix) {
			candidates = append(candidates, cmd)
		}
	}

	pathDirs := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))
	seen := make(map[string]bool)

	for _, dir := range pathDirs {
		
		files, err := os.ReadDir(dir)
		if err != nil {
			continue
		}

		for _, file := range files {
			name := file.Name()
			if strings.HasPrefix(name, prefix) && !seen[name] {
				info, err := file.Info()
				if err == nil && isExecutable(info.Mode()) {
					candidates = append(candidates, name)
					seen[name] = true
				}
			}
		}
	}
	return candidates
}

func isExecutable(mode fs.FileMode) bool {
	return mode&0111 != 0
}


Hey @Kushagra1480, could you upload your code to GitHub and share the link? It will be much easier to debug if I can run it directly.

Thanks for the response, @andy1li! Here’s a link to a repo with my code

https://github.com/Kushagra1480/w-shell

I’ve fixed the issue- the problem was that the state wasn’t retained across presses so I moved the state out to global variables instead.

1 Like

This topic was automatically closed 5 days after the last reply. New replies are no longer allowed.