Define immutable configuration searches
This commit is contained in:
266
internal/engine/discovery.go
Normal file
266
internal/engine/discovery.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Search is an immutable ordered description of possible host-file inputs.
|
||||
// It does not access the filesystem until a later loading integration consumes it.
|
||||
type Search struct {
|
||||
entries []searchEntry
|
||||
issues []string
|
||||
}
|
||||
|
||||
// FilenameGroup is an immutable ordered group used by a directory search.
|
||||
type FilenameGroup struct {
|
||||
kind filenameGroupKind
|
||||
names []string
|
||||
issues []string
|
||||
}
|
||||
|
||||
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
|
||||
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
|
||||
type DirectorySearchOptions struct {
|
||||
Filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// SearchOptions controls whether a later source consumption requires a match.
|
||||
type SearchOptions struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
type filenameGroupKind uint8
|
||||
|
||||
const (
|
||||
filenameGroupEmpty filenameGroupKind = iota
|
||||
filenameGroupNamed
|
||||
filenameGroupGeneric
|
||||
filenameGroupCustom
|
||||
)
|
||||
|
||||
type searchEntryKind uint8
|
||||
|
||||
const (
|
||||
searchEntryPath searchEntryKind = iota + 1
|
||||
searchEntryDirectory
|
||||
)
|
||||
|
||||
type directoryBaseKind uint8
|
||||
|
||||
const (
|
||||
directoryBaseLiteral directoryBaseKind = iota
|
||||
directoryBaseUnixLocal
|
||||
directoryBaseUnixEtc
|
||||
directoryBaseUserConfig
|
||||
directoryBaseHome
|
||||
directoryBaseWorking
|
||||
)
|
||||
|
||||
type searchEntry struct {
|
||||
kind searchEntryKind
|
||||
path string
|
||||
baseKind directoryBaseKind
|
||||
directory string
|
||||
appendSlug bool
|
||||
slugPrefix string
|
||||
filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||
func NamedFilenames() FilenameGroup {
|
||||
return FilenameGroup{kind: filenameGroupNamed}
|
||||
}
|
||||
|
||||
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||
func GenericFilenames() FilenameGroup {
|
||||
return FilenameGroup{kind: filenameGroupGeneric}
|
||||
}
|
||||
|
||||
// CustomFilenames selects literal filename components in the supplied order.
|
||||
func CustomFilenames(names ...string) FilenameGroup {
|
||||
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
|
||||
for _, name := range group.names {
|
||||
if !validFilenameComponent(name) {
|
||||
group.issues = append(group.issues, "custom filename must be a usable host filename component")
|
||||
}
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
// SearchDirectory searches the supplied directory using the selected filename groups.
|
||||
// With omitted options or nil Filenames, it selects Named then Generic filenames.
|
||||
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||
search := Search{}
|
||||
if len(supplied) > 1 {
|
||||
search.issues = append(search.issues, "directory search accepts at most one options value")
|
||||
}
|
||||
var groups []FilenameGroup
|
||||
if len(supplied) == 0 || supplied[0].Filenames == nil {
|
||||
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
} else {
|
||||
groups = cloneFilenameGroups(supplied[0].Filenames)
|
||||
}
|
||||
search.entries = append(search.entries, searchEntry{
|
||||
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
|
||||
directory: directory, filenames: groups,
|
||||
})
|
||||
return search
|
||||
}
|
||||
|
||||
// Paths describes literal host-file candidates in the supplied order.
|
||||
func Paths(paths ...string) Search {
|
||||
search := Search{entries: make([]searchEntry, 0, len(paths))}
|
||||
for _, path := range append([]string(nil), paths...) {
|
||||
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
// Searches concatenates descriptions in order and snapshots their entries.
|
||||
func Searches(searches ...Search) Search {
|
||||
result := Search{}
|
||||
for _, search := range append([]Search(nil), searches...) {
|
||||
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
|
||||
result.issues = append(result.issues, search.issues...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||
func UnixSystemDirectories() Search {
|
||||
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// UserConfigDirectory describes the Go user-configuration directory searches.
|
||||
func UserConfigDirectory() Search {
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// HomeDirectory describes the Go home-directory searches.
|
||||
func HomeDirectory() Search {
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// WorkingDirectory describes application-named files in the current directory.
|
||||
func WorkingDirectory() Search {
|
||||
return Search{entries: []searchEntry{{
|
||||
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
|
||||
filenames: []FilenameGroup{NamedFilenames()},
|
||||
}}}
|
||||
}
|
||||
|
||||
func defaultFilenameGroups() []FilenameGroup {
|
||||
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
}
|
||||
|
||||
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
|
||||
if groups == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]FilenameGroup, len(groups))
|
||||
for i, group := range groups {
|
||||
cloned[i] = FilenameGroup{
|
||||
kind: group.kind, names: append([]string(nil), group.names...),
|
||||
issues: append([]string(nil), group.issues...),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneSearchEntries(entries []searchEntry) []searchEntry {
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]searchEntry, len(entries))
|
||||
for i, entry := range entries {
|
||||
cloned[i] = entry
|
||||
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func validateSearch(search Search, application string) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
for _, issue := range search.issues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
slugNeeded := false
|
||||
for _, entry := range search.entries {
|
||||
switch entry.kind {
|
||||
case searchEntryPath:
|
||||
if entry.path == "" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
|
||||
}
|
||||
case searchEntryDirectory:
|
||||
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
|
||||
}
|
||||
if entry.appendSlug {
|
||||
slugNeeded = true
|
||||
}
|
||||
for _, group := range entry.filenames {
|
||||
switch group.kind {
|
||||
case filenameGroupEmpty, filenameGroupGeneric:
|
||||
case filenameGroupNamed:
|
||||
slugNeeded = true
|
||||
case filenameGroupCustom:
|
||||
default:
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
|
||||
}
|
||||
for _, issue := range group.issues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
}
|
||||
default:
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
|
||||
}
|
||||
}
|
||||
if slugNeeded && !validFilenameComponent(application) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
|
||||
}
|
||||
return canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func validFilenameComponent(value string) bool {
|
||||
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return validWindowsFilenameComponent(value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validWindowsFilenameComponent(value string) bool {
|
||||
if strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
base := strings.ToUpper(strings.TrimSuffix(value, filepath.Ext(value)))
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
152
internal/engine/discovery_test.go
Normal file
152
internal/engine/discovery_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("zero search diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty concatenation diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty path list diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}})
|
||||
if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 {
|
||||
t.Fatalf("valid preset slug diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionSnapshotsInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := []string{"first.yml", "second.yml"}
|
||||
pathSearch := Paths(paths...)
|
||||
paths[0] = "changed.yml"
|
||||
|
||||
customNames := []string{"one.yml", "two.yml"}
|
||||
group := CustomFilenames(customNames...)
|
||||
customNames[0] = "changed.yml"
|
||||
groups := []FilenameGroup{group}
|
||||
options := DirectorySearchOptions{Filenames: groups}
|
||||
directorySearch := SearchDirectory("configs", options)
|
||||
groups[0] = GenericFilenames()
|
||||
options.Filenames[0] = NamedFilenames()
|
||||
|
||||
searches := []Search{pathSearch, directorySearch}
|
||||
combined := Searches(searches...)
|
||||
searches[0] = Search{}
|
||||
|
||||
if got := combined.entries[0].path; got != "first.yml" {
|
||||
t.Fatalf("snapshotted path = %q", got)
|
||||
}
|
||||
gotNames := combined.entries[2].filenames[0].names
|
||||
if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" {
|
||||
t.Fatalf("snapshotted custom names = %#v", gotNames)
|
||||
}
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("snapshot description diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
omitted := SearchDirectory("configs")
|
||||
if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric {
|
||||
t.Fatalf("omitted groups = %#v", omitted.entries)
|
||||
}
|
||||
explicitNil := SearchDirectory("configs", DirectorySearchOptions{})
|
||||
if len(explicitNil.entries[0].filenames) != 2 {
|
||||
t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames)
|
||||
}
|
||||
explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 {
|
||||
t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames)
|
||||
}
|
||||
if len(validateSearch(explicitEmpty, "")) != 0 {
|
||||
t.Fatal("explicit empty filename selection should need no slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{})
|
||||
if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 {
|
||||
t.Fatal("multiple option values should be invalid")
|
||||
}
|
||||
invalid := Paths("")
|
||||
combined := Searches(Paths("good.yml"), invalid)
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 {
|
||||
t.Fatal("concatenation lost a constructor error")
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("later invalid path was hidden by an earlier component")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")}
|
||||
search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups})
|
||||
got := search.entries[0].filenames
|
||||
if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" {
|
||||
t.Fatalf("filename group order = %#v", got)
|
||||
}
|
||||
if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 {
|
||||
t.Fatalf("empty custom group = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameComponentValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("validFilenameComponent(%q) = true", value)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, value := range []string{"CON", "nul.txt", "name.", "bad:name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, value := range []string{"name with spaces", "naïve"} {
|
||||
if !validFilenameComponent(value) {
|
||||
t.Errorf("host-valid component %q rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("invalid custom filename was not retained for validation")
|
||||
}
|
||||
for _, value := range []string{"CON", "nul.txt", "COM¹.log", "LPT³.txt", "name.", "bad:name", "bad\x01name"} {
|
||||
if validWindowsFilenameComponent(value) {
|
||||
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"name with spaces", "naïve"} {
|
||||
if !validWindowsFilenameComponent(value) {
|
||||
t.Errorf("valid Windows component %q rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user