
Search.pudu
Pudu91 lines3.3 KB
1/** @Domain.Docs.Search.Module — ranks documentation sections for a query */2module PuduLangMcp.Domain.Docs.Search34import Std.Char as Char5import Std.List as List6import Std.Math as Math7import Std.Text as Text8import PuduLangMcp.Domain.Docs.Chapter as Chapter910/** @Domain.Docs.Search.Hit — a ranked documentation section */11export type Hit = { section: Chapter.Section, score: Int }121314const STOP_WORDS: Set[Str] = setOf([15 "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for", "from", "how", "i", "if",16 "in", "is", "it", "me", "my", "of", "on", "or", "pudu", "should", "the", "to", "use", "what", "when",17 "where", "which", "why", "with", "write"18 ])192021const PHRASE_IN_HEADING: Int = 402223const PHRASE_IN_TEXT: Int = 122425const TERM_IN_HEADING: Int = 102627const TERM_IN_TEXT: Int = 22829const MAX_COUNTED_OCCURRENCES: Int = 53031const EVERY_TERM: Int = 153233const MIN_PHRASE_LENGTH: Int = 3343536export fn terms(query: Str) -> Array[Str] {37 var found: Array[Str] = []38 var current = ""39 for character in (query.toLower() + " ").chars() {40 if isTermCharacter(character) {41 current = current + Text.singleton(character)42 } else {43 let term = Text.dropAround(current, |c: Char| c == '.')44 if !term.isEmpty() && !STOP_WORDS.contains(term) && !found.contains(term) { found = found.push(term) }45 current = ""46 }47 }48 found49}505152export fn score(section: &Chapter.Section, query: Str, words: &Array[Str]) -> Int {53 if words.isEmpty() { return 0 }54 let heading = section.heading.toLower()55 let text = section.text.toLower()56 let phrase = query.trim().toLower()57 var total = 058 if phrase.length() >= MIN_PHRASE_LENGTH {59 if heading.contains(phrase) { total = total + PHRASE_IN_HEADING }60 if text.contains(phrase) { total = total + PHRASE_IN_TEXT }61 }62 var matched = 063 for word in *words {64 let inHeading = heading.contains(word)65 let occurrences = Text.countOccurrences(text, word)66 if inHeading { total = total + TERM_IN_HEADING }67 let counted = Math.min(occurrences, MAX_COUNTED_OCCURRENCES)68 total = total + counted * TERM_IN_TEXT69 if inHeading || occurrences > 0 { matched = matched + 1 }70 }71 if matched == words.length() { total = total + EVERY_TERM }72 if matched == 0 { 0 } else { total }73}747576export fn search(sections: &Array[Chapter.Section], query: Str, limit: Int) -> Array[Hit] {77 let words = terms(query)78 if words.isEmpty() { return [] }79 var ranked: Array[Hit] = []80 for section in *sections {81 let points = score(§ion, query, &words)82 if points > 0 { ranked = ranked.push(Hit{section: section, score: points}) }83 }84 List.take(&List.sortOn(&ranked, |hit: Hit| 0 - hit.score), limit)85}868788fn isTermCharacter(character: Char) -> Bool {89 Char.isAlphanumeric(character) || character == '_' || character == '.'90}91