import type { SearchKind } from '@/entities/search' /** Global-search filters split out of the raw query text. */ export interface SearchFilters { text: string spaceName?: string repoName?: string language?: string state?: string kinds?: SearchKind[] } /** One qualifier the user typed, rendered as a removable pill. */ export type ActiveFilter = | { field: 'repo' | 'language' | 'state'; token: string } | { field: 'kind'; kind: SearchKind; token: string } /** Shorthands mapped to the language labels the code index stores. */ const LANGUAGE_ALIASES: Record = { rs: 'Rust', rust: 'Rust', js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript', tsx: 'TypeScript (React)', py: 'Python', python: 'Python', go: 'Go', golang: 'Go', java: 'Java', kt: 'Kotlin', kotlin: 'Kotlin', c: 'C', cpp: 'C++', cs: 'C#', rb: 'Ruby', ruby: 'Ruby', php: 'PHP', sh: 'Shell', bash: 'Shell', shell: 'Shell', html: 'HTML', css: 'CSS', scss: 'SCSS', vue: 'Vue', svelte: 'Svelte', sql: 'SQL', json: 'JSON', yaml: 'YAML', toml: 'TOML', md: 'Markdown', markdown: 'Markdown', } const KIND_ALIASES: Record = { code: 'code', symbol: 'symbol', symbols: 'symbol', issue: 'issue', issues: 'issue', pr: 'pull', prs: 'pull', pull: 'pull', pulls: 'pull', 'pull-request': 'pull', 'pull-requests': 'pull', message: 'message', messages: 'message', chat: 'message', } const STATES = new Set(['open', 'closed', 'merged']) /** Split on whitespace while keeping `key:"quoted value"` in one token. */ function tokenize(input: string): string[] { const tokens: string[] = [] let current = '' let quoted = false for (const char of input) { if (char === '"') { quoted = !quoted continue } if (!quoted && /\s/.test(char)) { if (current) tokens.push(current) current = '' continue } current += char } if (current) tokens.push(current) return tokens } function repoToken(filters: SearchFilters): string { const path = filters.spaceName ? `${filters.spaceName}/${filters.repoName ?? ''}` : filters.repoName ?? '' return `repo:${path}` } /** * GitHub-style qualifiers (`repo:space/repo`, `language:rust`, `state:open`, * `type:pr` / `is:issue`) become API filters; everything else stays free text. * Unknown or valueless tokens are searched as-is instead of being dropped. */ export function parseSearchQuery(input: string): SearchFilters { const terms: string[] = [] const kinds = new Set() const filters: SearchFilters = { text: '' } for (const token of tokenize(input)) { const at = token.indexOf(':') if (at <= 0) { terms.push(token) continue } const key = token.slice(0, at).toLowerCase() const value = token.slice(at + 1).trim() if (!value) { terms.push(token) continue } const lower = value.toLowerCase() if (key === 'repo') { // `repo:space/repo` names both halves; a bare `repo:name` only the repo. const [space, ...rest] = value.split('/') if (rest.length) { filters.spaceName = space filters.repoName = rest.join('/') } else { filters.repoName = space } } else if (key === 'space' || key === 'org') { filters.spaceName = value } else if (key === 'language' || key === 'lang') { filters.language = LANGUAGE_ALIASES[lower] ?? value } else if (key === 'state') { filters.state = lower } else if (key === 'is' || key === 'type' || key === 'kind') { const kind = KIND_ALIASES[lower] if (kind) kinds.add(kind) else if (key === 'is' && STATES.has(lower)) filters.state = lower else terms.push(token) } else { terms.push(token) } } filters.text = terms.join(' ') if (kinds.size) filters.kinds = [...kinds] return filters } /** Qualifiers the query carries, in the order the dialog renders them. */ export function activeFilters(filters: SearchFilters): ActiveFilter[] { const chips: ActiveFilter[] = [] if (filters.spaceName || filters.repoName) { chips.push({ field: 'repo', token: repoToken(filters) }) } if (filters.language) chips.push({ field: 'language', token: `language:${filters.language}` }) if (filters.state) chips.push({ field: 'state', token: `state:${filters.state}` }) for (const kind of filters.kinds ?? []) { chips.push({ field: 'kind', kind, token: `type:${kind}` }) } return chips } /** Query text with one pill dropped and the remaining filters kept. */ export function removeFilter(filters: SearchFilters, drop: ActiveFilter): string { const kept: string[] = [] if (filters.text.trim()) kept.push(filters.text.trim()) if (drop.field !== 'repo' && (filters.spaceName || filters.repoName)) kept.push(repoToken(filters)) if (drop.field !== 'language' && filters.language) kept.push(`language:${filters.language}`) if (drop.field !== 'state' && filters.state) kept.push(`state:${filters.state}`) for (const kind of filters.kinds ?? []) { if (drop.field === 'kind' && drop.kind === kind) continue kept.push(`type:${kind}`) } return kept.join(' ') }