// Package search parses the general opportunity search: words and quoted // phrases, each optionally with a prompt naming the field it searches // (title:, buyer:, buyer_id:, cpv:) and a leading "!" to negate it. The // store runs a parsed Query (store.SearchOpportunities). package search import ( "regexp" "strings" "unicode" ) // Field is what a term searches. type Field int // Values of Field. const ( // FieldText is a term with no prompt: the title or the description. FieldText Field = iota // FieldTitle is title:, the title only. FieldTitle // FieldBuyer is buyer:, the buyer's name. FieldBuyer // FieldBuyerID is buyer_id:, the buyer's ID, matched whole. FieldBuyerID // FieldCPV is cpv:, a CPV code prefix or words of a CPV description. FieldCPV ) // Prompt is a known prompt, with a line of help for the user. type Prompt struct { Name string `json:"name"` Help string `json:"help"` } // Example is an example of the search syntax, with a line of help for the // user. type Example struct { Example string `json:"example"` Help string `json:"help"` } // Prompts are the known prompts, in the order to show them. var Prompts = []Prompt{ {Name: "title", Help: "Words in the title only."}, {Name: "buyer", Help: "Words in the buyer's name."}, { Name: "buyer_id", Help: "The buyer's ID, whole and in any case (e.g. GB-FTS-148803).", }, { Name: "cpv", Help: "The start of a CPV code (cpv:72), or words in a CPV code's description (cpv:food).", }, } // Syntax explains the search syntax, in the order to show it. var Syntax = []Example{ { Example: "cloud hosting", Help: "Words without a prompt are searched for in the title and the description. Every word must be found, though not necessarily in the same one.", }, { Example: "migr", Help: "A word also matches the start of a longer word, and other forms of it: migr finds migration, and migrating finds migration.", }, { Example: `"road repairs"`, Help: "Quotes search for a phrase: its words together, in that order.", }, { Example: `title:"road repairs"`, Help: "A prompt searches only its own field, for one word or one quoted phrase.", }, { Example: "buyer:leeds buyer:bradford", Help: "Repeats of the same prompt match if any of them does: Leeds or Bradford.", }, { Example: "cloud buyer:leeds cpv:72", Help: "Different prompts, and words without one, must all match.", }, { Example: "!cloud !buyer:leeds", Help: "! in front of a word, a phrase or a prompt leaves out what it matches.", }, { Example: "cpv:72 !cpv:7221", Help: "CPV terms are checked against each of an opportunity's codes: this needs a code starting 72 that doesn't start 7221. !cpv: without cpv: leaves out every opportunity with a matching code.", }, } // prompts maps each known prompt's name to its field. var prompts = map[string]Field{ "title": FieldTitle, "buyer": FieldBuyer, "buyer_id": FieldBuyerID, "cpv": FieldCPV, } // promptPattern matches a prompt at the start of a term: a name of letters // and underscores, then a colon. A colon after anything else (12:00) is // part of the term. var promptPattern = regexp.MustCompile(`^([A-Za-z_]+):`) // cpvCodePattern matches a CPV code or the start of one, with an optional // check digit. var cpvCodePattern = regexp.MustCompile(`^([0-9]+)(?:-[0-9])?$`) // Term is one search term: a word or a quoted phrase, as typed. type Term struct { Field Field Negated bool Value string } // Words returns the term's words, split as the store's full-text index // splits them: on anything but letters and numbers. func (t Term) Words() []string { return strings.FieldsFunc(t.Value, func(char rune) bool { return !unicode.IsLetter(char) && !unicode.IsNumber(char) }) } // CPVCode returns the digits of a cpv: term that is a code or the start of // one, without its check digit, and reports whether it is one. Any other // cpv: term searches CPV descriptions. func (t Term) CPVCode() (string, bool) { match := cpvCodePattern.FindStringSubmatch(t.Value) if match == nil { return "", false } return match[1], true } // usable reports whether the term can be searched for: a buyer ID that // isn't blank, or any other term with at least one word. func (t Term) usable() bool { if t.Field == FieldBuyerID { return strings.TrimSpace(t.Value) != "" } return len(t.Words()) > 0 } // Query is a parsed search. type Query struct { // Terms are the terms to search for, all usable. Terms []Term // UnknownPrompts are the prompts that aren't known, as typed, without // their colon, once each. Their terms are left out of Terms. UnknownPrompts []string } // MatchesAll reports whether the query has no terms, so it matches every // opportunity. func (q Query) MatchesAll() bool { return len(q.Terms) == 0 } // Parse parses text as a search. It never fails: text that isn't valid // syntax, such as an unclosed quote, is read as plain words, and a term // with no words (title: or !!!) is left out. func Parse(text string) Query { query := Query{ UnknownPrompts: []string{}, } unknown := map[string]bool{} for rest := strings.TrimSpace(text); rest != ""; { var term Term var prompt string var known bool term, prompt, known, rest = nextTerm(rest) rest = strings.TrimLeftFunc(rest, unicode.IsSpace) switch { case !known: if !unknown[prompt] { unknown[prompt] = true query.UnknownPrompts = append(query.UnknownPrompts, prompt) } case term.usable(): query.Terms = append(query.Terms, term) } } return query } // nextTerm reads the term at the start of text, which mustn't start with // space. It returns the term, its prompt as typed ("" if none), whether the // prompt is known, and the text after the term. func nextTerm( text string, ) (term Term, prompt string, known bool, rest string) { if strings.HasPrefix(text, "!") { term.Negated = true text = text[1:] } if match := promptPattern.FindStringSubmatch(text); match != nil { prompt = match[1] text = text[len(match[0]):] } term.Field, known = prompts[strings.ToLower(prompt)] if prompt == "" { term.Field, known = FieldText, true } if strings.HasPrefix(text, `"`) { term.Value, rest, _ = strings.Cut(text[1:], `"`) return term, prompt, known, rest } end := strings.IndexFunc(text, unicode.IsSpace) if end < 0 { end = len(text) } term.Value = text[:end] return term, prompt, known, text[end:] }