563bb99
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<string, string> = {
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<string, SearchKind> = {
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<SearchKind>()
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(' ')
}