diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d264d58..f2596e4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -291,6 +291,12 @@ count; target expansion; group suffix preservation; shorter fallback after a failed longer token match; unknown tokens; empty suffixes. The compiler and direct resolver agree on these terminating cases. +**Completed:** Added the independent longest-prefix resolver and compiled its +trie into a finite stack machine with literal and `OTHER` symbols, end and +flag-boundary markers, shorter-match fallback, restoration, and normalized +pushes. A test-only interpreter matches direct resolution for the recorded +finite cases. Verified with `go test ./internal/alias`. + ## Stage 3: Prove and test termination validation **Work** diff --git a/internal/alias/machine.go b/internal/alias/machine.go new file mode 100644 index 0000000..8af459a --- /dev/null +++ b/internal/alias/machine.go @@ -0,0 +1,143 @@ +package alias + +import ( + "sort" + + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +type symbolKind uint8 + +const ( + literalSymbol symbolKind = iota + otherSymbol + endSymbol + boundarySymbol +) + +type symbol struct { + kind symbolKind + value string +} + +type transitionKey struct { + control int + read symbol +} + +type transition struct { + next int + push []symbol + rule int +} + +// Machine is a finite-control pushdown rewrite model compiled from a resolver. +// Its transitions replace the stack's first symbol with zero to two symbols. +type Machine struct { + start int + halt int + transitions map[transitionKey]transition + alphabet []symbol + literals map[string]struct{} +} + +// Compile builds a finite stack machine with explicit lookup, restoration, +// and rewrite transitions for the mappings. +func Compile(mappings []Mapping) (*Resolver, *Machine, error) { + resolver, err := NewResolver(mappings) + if err != nil { + return nil, nil, err + } + return resolver, compileMachine(resolver), nil +} + +func compileMachine(resolver *Resolver) *Machine { + // Trie controls only move to child nodes. Helper controls prepend a fixed + // number of saved symbols and then halt or return to the trie root after a + // selected mapping has been completed. + machine := &Machine{ + start: 0, + halt: len(resolver.trie), + transitions: make(map[transitionKey]transition), + literals: make(map[string]struct{}), + } + for _, mapping := range resolver.mappings { + for _, path := range []commandpath.Path{mapping.Source, mapping.Target} { + for _, component := range path { + machine.literals[component] = struct{}{} + } + } + } + for literal := range machine.literals { + machine.alphabet = append(machine.alphabet, symbol{kind: literalSymbol, value: literal}) + } + sort.Slice(machine.alphabet, func(i, j int) bool { return machine.alphabet[i].value < machine.alphabet[j].value }) + machine.alphabet = append(machine.alphabet, symbol{kind: otherSymbol}, symbol{kind: endSymbol}, symbol{kind: boundarySymbol}) + + nextControl := machine.halt + 1 + for nodeIndex, node := range resolver.trie { + for _, input := range machine.alphabet { + if input.kind == literalSymbol { + if childIndex, exists := node.children[input.value]; exists { + machine.transitions[transitionKey{control: nodeIndex, read: input}] = transition{next: childIndex, rule: -1} + continue + } + } + + word, next, rule := machine.lookupOutcome(resolver, nodeIndex, input) + machine.addWordTransition(nodeIndex, input, word, next, rule, &nextControl) + } + } + return machine +} + +func (machine *Machine) lookupOutcome(resolver *Resolver, nodeIndex int, input symbol) ([]symbol, int, int) { + node := resolver.trie[nodeIndex] + word := make([]symbol, 0, len(node.path)+1) + next := machine.halt + rule := -1 + if node.best >= 0 { + mapping := resolver.mappings[node.best] + matchedLength := len(mapping.Source) + for _, component := range mapping.Target { + word = append(word, symbol{kind: literalSymbol, value: component}) + } + for _, component := range node.path[matchedLength:] { + word = append(word, symbol{kind: literalSymbol, value: component}) + } + next = machine.start + rule = node.best + } else { + for _, component := range node.path { + word = append(word, symbol{kind: literalSymbol, value: component}) + } + } + word = append(word, input) + return word, next, rule +} + +func (machine *Machine) addWordTransition(control int, read symbol, word []symbol, next, rule int, nextControl *int) { + key := transitionKey{control: control, read: read} + if len(word) <= 2 { + machine.transitions[key] = transition{next: next, push: append([]symbol(nil), word...), rule: rule} + return + } + + helper := *nextControl + *nextControl = *nextControl + 1 + machine.transitions[key] = transition{next: helper, push: append([]symbol(nil), word[len(word)-2:]...), rule: rule} + for index := len(word) - 3; index >= 0; index-- { + following := next + if index > 0 { + following = *nextControl + *nextControl = *nextControl + 1 + } + expectedTop := word[index+1] + machine.transitions[transitionKey{control: helper, read: expectedTop}] = transition{ + next: following, + push: []symbol{word[index], expectedTop}, + rule: rule, + } + helper = following + } +} diff --git a/internal/alias/resolver.go b/internal/alias/resolver.go new file mode 100644 index 0000000..62f22a4 --- /dev/null +++ b/internal/alias/resolver.go @@ -0,0 +1,134 @@ +// Package alias models application command-path rewriting independently of +// command execution and the parsing backend. +package alias + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +// Mapping replaces Source with Target when Source is the longest matching +// prefix of an entered command path. +type Mapping struct { + Source commandpath.Path + Target commandpath.Path +} + +type trieNode struct { + children map[string]int + mapping int + best int + parent int + path commandpath.Path +} + +// Resolver selects longest matching command-path mappings. +type Resolver struct { + mappings []Mapping + trie []trieNode +} + +// NewResolver builds a resolver from normalized command-path mappings. +func NewResolver(mappings []Mapping) (*Resolver, error) { + resolver := &Resolver{trie: []trieNode{{mapping: -1, best: -1, parent: -1}}} + for _, mapping := range mappings { + if len(mapping.Source) == 0 { + return nil, fmt.Errorf("command alias source path must not be empty") + } + if len(mapping.Target) == 0 { + return nil, fmt.Errorf("command alias target path must not be empty") + } + for _, component := range append(mapping.Source.Clone(), mapping.Target...) { + if err := commandpath.ValidateComponent(component); err != nil { + return nil, err + } + } + normalized := Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()} + index := len(resolver.mappings) + resolver.mappings = append(resolver.mappings, normalized) + nodeIndex := 0 + for _, component := range normalized.Source { + node := &resolver.trie[nodeIndex] + if node.children == nil { + node.children = make(map[string]int) + } + childIndex, exists := node.children[component] + if !exists { + childIndex = len(resolver.trie) + node.children[component] = childIndex + childPath := append(resolver.trie[nodeIndex].path.Clone(), component) + resolver.trie = append(resolver.trie, trieNode{mapping: -1, best: -1, parent: nodeIndex, path: childPath}) + } + nodeIndex = childIndex + } + node := &resolver.trie[nodeIndex] + if node.mapping >= 0 { + previous := resolver.mappings[node.mapping] + if previous.Target.Equal(normalized.Target) { + resolver.mappings = resolver.mappings[:index] + continue + } + return nil, fmt.Errorf("conflicting command aliases for %q", normalized.Source.String()) + } + node.mapping = index + node.path = normalized.Source.Clone() + } + resolver.setFallbacks() + return resolver, nil +} + +func (resolver *Resolver) setFallbacks() { + for index := 0; index < len(resolver.trie); index++ { + node := &resolver.trie[index] + if index > 0 { + node.best = resolver.trie[node.parent].best + } + if node.mapping >= 0 { + node.best = node.mapping + } + } +} + +// ResolveOnce applies at most one longest-prefix mapping. It returns the +// original input unchanged when no mapping matches. +func (resolver *Resolver) ResolveOnce(path commandpath.Path) (commandpath.Path, Mapping, bool) { + if resolver == nil { + return path.Clone(), Mapping{}, false + } + nodeIndex := 0 + matched := -1 + matchedLength := 0 + for index, component := range path { + childIndex, exists := resolver.trie[nodeIndex].children[component] + if !exists { + break + } + nodeIndex = childIndex + if mapping := resolver.trie[nodeIndex].mapping; mapping >= 0 { + matched = mapping + matchedLength = index + 1 + } + } + if matched < 0 { + return path.Clone(), Mapping{}, false + } + mapping := resolver.mappings[matched] + resolved := make(commandpath.Path, 0, len(mapping.Target)+len(path)-matchedLength) + resolved = append(resolved, mapping.Target...) + resolved = append(resolved, path[matchedLength:]...) + return resolved, Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()}, true +} + +// Resolve applies mappings until no source matches. Installation validates +// termination before a resolver is used for application input. +func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path { + current := path.Clone() + for { + next, _, matched := resolver.ResolveOnce(current) + if !matched { + return current + } + current = next + } +} diff --git a/internal/alias/resolver_test.go b/internal/alias/resolver_test.go new file mode 100644 index 0000000..773923c --- /dev/null +++ b/internal/alias/resolver_test.go @@ -0,0 +1,185 @@ +package alias + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +func TestResolverUsesLongestTokenPrefixAndPreservesSuffix(t *testing.T) { + resolver, err := NewResolver([]Mapping{ + {Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}, + {Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}}, + }) + if err != nil { + t.Fatal(err) + } + + got := resolver.Resolve(commandpath.Path{"list", "collections", "extra"}) + want := commandpath.Path{"app", "collections", "extra"} + if !got.Equal(want) { + t.Fatalf("Resolve() = %#v, want %#v", got, want) + } + got = resolver.Resolve(commandpath.Path{"list", "prompts"}) + want = commandpath.Path{"promptkit", "list", "prompts"} + if !got.Equal(want) { + t.Fatalf("Resolve() = %#v, want %#v", got, want) + } + reversed, err := NewResolver([]Mapping{ + {Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}}, + {Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}, + }) + if err != nil { + t.Fatal(err) + } + if got := reversed.Resolve(commandpath.Path{"list", "collections", "extra"}); !got.Equal(commandpath.Path{"app", "collections", "extra"}) { + t.Fatalf("reverse-order Resolve() = %#v", got) + } +} + +func TestResolverFallsBackAfterLongerTokenMatchFails(t *testing.T) { + resolver, err := NewResolver([]Mapping{ + {Source: commandpath.Path{"a"}, Target: commandpath.Path{"short"}}, + {Source: commandpath.Path{"a", "b", "c"}, Target: commandpath.Path{"long"}}, + }) + if err != nil { + t.Fatal(err) + } + got := resolver.Resolve(commandpath.Path{"a", "b", "x"}) + want := commandpath.Path{"short", "b", "x"} + if !got.Equal(want) { + t.Fatalf("Resolve() = %#v, want %#v", got, want) + } +} + +func TestResolverDoesNotMatchUnknownTokens(t *testing.T) { + resolver, err := NewResolver([]Mapping{{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}}) + if err != nil { + t.Fatal(err) + } + input := commandpath.Path{"listing", "collections"} + got := resolver.Resolve(input) + if !got.Equal(input) { + t.Fatalf("Resolve() = %#v, want unchanged %#v", got, input) + } +} + +func TestCompiledMachineMatchesDirectResolver(t *testing.T) { + mappings := []Mapping{ + {Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}, + {Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}}, + {Source: commandpath.Path{"pk"}, Target: commandpath.Path{"promptkit"}}, + {Source: commandpath.Path{"promptkit", "list", "bundles"}, Target: commandpath.Path{"promptkit", "list", "collections"}}, + {Source: commandpath.Path{"short"}, Target: commandpath.Path{"promptkit", "list"}}, + {Source: commandpath.Path{"short", "b", "c"}, Target: commandpath.Path{"long"}}, + } + resolver, machine, err := Compile(mappings) + if err != nil { + t.Fatal(err) + } + for key, operation := range machine.transitions { + if len(operation.push) > 2 { + t.Fatalf("transition %+v pushes %d symbols", key, len(operation.push)) + } + } + cases := []commandpath.Path{ + {"list"}, + {"list", "prompts"}, + {"list", "collections"}, + {"list", "collections", "extra"}, + {"pk", "list", "bundles", "tail"}, + {"short", "b", "x"}, + {"unknown", "operand"}, + {}, + } + for _, marker := range []symbolKind{endSymbol, boundarySymbol} { + for _, input := range cases { + t.Run(input.String(), func(t *testing.T) { + got, err := runMachineForTest(machine, input, marker) + if err != nil { + t.Fatal(err) + } + want := expectedSymbols(machine, resolver.Resolve(input), marker) + if !reflect.DeepEqual(got, want) { + t.Fatalf("machine result = %#v, want direct result %#v", got, want) + } + }) + } + } +} + +func TestResolverTreatsIdenticalMappingsAsIdempotent(t *testing.T) { + mapping := Mapping{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}} + resolver, err := NewResolver([]Mapping{mapping, mapping}) + if err != nil { + t.Fatal(err) + } + got := resolver.Resolve(commandpath.Path{"list", "prompts"}) + if want := (commandpath.Path{"promptkit", "list", "prompts"}); !got.Equal(want) { + t.Fatalf("Resolve() = %#v, want %#v", got, want) + } +} + +func TestResolverRejectsInvalidAndConflictingMappings(t *testing.T) { + for name, mappings := range map[string][]Mapping{ + "empty source": {{Target: commandpath.Path{"target"}}}, + "empty target": {{Source: commandpath.Path{"source"}}}, + "conflicting target": { + {Source: commandpath.Path{"source"}, Target: commandpath.Path{"first"}}, + {Source: commandpath.Path{"source"}, Target: commandpath.Path{"second"}}, + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := NewResolver(mappings); err == nil { + t.Fatal("NewResolver() succeeded") + } + }) + } +} + +func expectedSymbols(machine *Machine, path commandpath.Path, marker symbolKind) []symbol { + result := make([]symbol, 0, len(path)+1) + for _, token := range path { + result = append(result, machine.classify(token)) + } + result = append(result, symbol{kind: marker}) + return result +} + +func runMachineForTest(machine *Machine, input commandpath.Path, marker symbolKind) ([]symbol, error) { + stack := expectedSymbols(machine, input, marker) + control := machine.start + for steps := 0; steps < 10000; steps++ { + if control == machine.halt { + return stack, nil + } + if len(stack) == 0 { + return nil, errMachineStackEmpty + } + operation, ok := machine.transitions[transitionKey{control: control, read: stack[0]}] + if !ok { + return nil, errMachineTransitionMissing + } + stack = append(append([]symbol(nil), operation.push...), stack[1:]...) + control = operation.next + } + return nil, errMachineTestDidNotHalt +} + +var ( + errMachineStackEmpty = testError("compiled machine emptied its stack") + errMachineTransitionMissing = testError("compiled machine has no transition") + errMachineTestDidNotHalt = testError("compiled machine did not halt in the test interpreter") +) + +type testError string + +func (err testError) Error() string { return string(err) } + +func (machine *Machine) classify(token string) symbol { + if _, exists := machine.literals[token]; exists { + return symbol{kind: literalSymbol, value: token} + } + return symbol{kind: otherSymbol} +}