Model command alias prefix rewriting
This commit is contained in:
@@ -291,6 +291,12 @@ count; target expansion; group suffix preservation; shorter fallback after
|
|||||||
a failed longer token match; unknown tokens; empty suffixes. The compiler
|
a failed longer token match; unknown tokens; empty suffixes. The compiler
|
||||||
and direct resolver agree on these terminating cases.
|
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
|
## Stage 3: Prove and test termination validation
|
||||||
|
|
||||||
**Work**
|
**Work**
|
||||||
|
|||||||
143
internal/alias/machine.go
Normal file
143
internal/alias/machine.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/alias/resolver.go
Normal file
134
internal/alias/resolver.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
185
internal/alias/resolver_test.go
Normal file
185
internal/alias/resolver_test.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user