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 .yml followed by .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 } }