import { normalizeHandle } from "#lib/handle.ts";
export type TicketState = "open" | "closed" | "completed";
export interface QueryValue {
text: string;
quoted: boolean;
start: number;
end: number;
}
interface TokenBase {
negated: boolean;
/** the full token with the minus sign */
start: number;
end: number;
}
export type QueryToken =
| (TokenBase & { kind: "word"; text: string })
| (TokenBase & { kind: "phrase"; text: string })
| (TokenBase & {
kind: "qualifier";
key: string;
/** the end of the key and the colon */
keyEnd: number;
values: QueryValue[];
known: boolean;
});
export const QUALIFIERS = ["is", "label", "author", "sort"] as const;
export type QualifierKey = (typeof QUALIFIERS)[number];
const isSpace = (ch: string | undefined) => ch !== undefined && /\s/.test(ch);
/** a phrase without a closing quote goes to the end */
const scanPhrase = (input: string, at: number): { text: string; end: number } => {
let i = at + 1;
let text = "";
while (i < input.length) {
const ch = input[i];
if (ch === "\\") {
if (i + 1 < input.length) {
text += input[i + 1];
i += 2;
} else {
text += ch;
i += 1;
}
continue;
}
if (ch === '"') return { text, end: i + 1 };
text += ch;
i += 1;
}
return { text, end: i };
};
const scanValues = (input: string, at: number): { values: QueryValue[]; end: number } => {
const values: QueryValue[] = [];
let i = at;
for (;;) {
if (input[i] === '"') {
const phrase = scanPhrase(input, i);
values.push({ text: phrase.text, quoted: true, start: i, end: phrase.end });
i = phrase.end;
} else {
let j = i;
while (j < input.length && !isSpace(input[j]) && input[j] !== ",") j += 1;
values.push({ text: input.slice(i, j), quoted: false, start: i, end: j });
i = j;
}
if (input[i] !== ",") return { values, end: i };
i += 1;
}
};
const KEY = /^[A-Za-z_]+:/;
export const tokenize = (
input: string,
qualifiers: readonly string[] = QUALIFIERS
): QueryToken[] => {
const tokens: QueryToken[] = [];
let i = 0;
while (i < input.length) {
if (isSpace(input[i])) {
i += 1;
continue;
}
const start = i;
const negated = input[i] === "-" && i + 1 < input.length && !isSpace(input[i + 1]);
const body = negated ? i + 1 : i;
if (input[body] === '"') {
const phrase = scanPhrase(input, body);
tokens.push({ kind: "phrase", negated, start, end: phrase.end, text: phrase.text });
i = phrase.end;
continue;
}
const key = KEY.exec(input.slice(body))?.[0];
if (key) {
const name = key.slice(0, -1).toLowerCase();
const { values, end } = scanValues(input, body + key.length);
tokens.push({
kind: "qualifier",
negated,
start,
end,
key: name,
keyEnd: body + key.length,
values,
known: qualifiers.includes(name)
});
i = end;
continue;
}
let end = body;
while (end < input.length && !isSpace(input[end])) end += 1;
tokens.push({ kind: "word", negated, start, end, text: input.slice(body, end) });
i = end;
}
return tokens;
};
const STATE_VALUES: Record<string, TicketState> = {
open: "open",
closed: "closed",
completed: "completed",
merged: "completed"
};
export const SORT_VALUES = new Set([
"relevance",
"best-match",
"created-desc",
"created",
"newest",
"created-asc",
"oldest"
]);
/** the problem with a value or null if the value is valid */
export const valueProblem = (key: QualifierKey, value: string): string | null => {
const v = value.trim().toLowerCase();
if (v === "") return `\`${key}:\` needs a value`;
const ok = (() => {
switch (key) {
case "is":
return v in STATE_VALUES;
case "sort":
return SORT_VALUES.has(v);
case "label":
case "author":
return true;
}
})();
return ok ? null : `\`${key}:${value}\` is not valid`;
};
/** the positive states in the query or null if there are none */
export const statesOf = (query: string): TicketState[] | null => {
const states = new Set<TicketState>();
for (const token of tokenize(query)) {
if (token.kind !== "qualifier" || token.negated) continue;
if (token.key !== "is") continue;
for (const value of token.values) {
const state = STATE_VALUES[value.text.trim().toLowerCase()];
if (state) states.add(state);
}
}
return states.size > 0 ? [...states] : null;
};
/** the active tab state if the query has one state only */
export const activeState = (query: string): TicketState | null => {
const states = statesOf(query);
return states?.length === 1 ? states[0] : null;
};
const isStateClause = (token: QueryToken) =>
token.kind === "qualifier" &&
token.key === "is" &&
token.values.some((value) => value.text.trim().toLowerCase() in STATE_VALUES);
const join = (parts: string[]) => parts.filter(Boolean).join(" ");
/** removes all state clauses and keeps the remaining text */
export const withoutStates = (query: string): string => {
let out = "";
let cursor = 0;
for (const token of tokenize(query)) {
if (!isStateClause(token)) continue;
out += query.slice(cursor, token.start);
cursor = token.end;
}
return (out + query.slice(cursor)).replace(/\s+/g, " ").trim();
};
/** the query for a state tab */
export const withState = (query: string, state: TicketState, isPull = false): string =>
join([`is:${state === "completed" && isPull ? "merged" : state}`, withoutStates(query)]);
/** adds quotes to a value that contains spaces or commas */
export const quoteValue = (value: string): string =>
value === "" || /[\s,"\\]/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value;
export const DEFAULT_QUERY = "is:open";
/** replaces author handles with dids and returns the handles that did not resolve */
export const resolveAuthors = async (
query: string,
resolve: (handle: string) => Promise<string | undefined>
): Promise<{ query: string; unresolved: string[] }> => {
const handles = tokenize(query)
.flatMap((token) =>
token.kind === "qualifier" && token.key === "author" ? token.values : []
)
.map((value) => ({ value, handle: normalizeHandle(value.text) }))
.filter(({ handle }) => handle !== "" && !handle.startsWith("did:"));
const dids = await Promise.all(handles.map(({ handle }) => resolve(handle)));
const unresolved: string[] = [];
let out = "";
let cursor = 0;
handles.forEach(({ value, handle }, index) => {
const did = dids[index];
if (!did) {
unresolved.push(handle);
return;
}
out += query.slice(cursor, value.start) + did;
cursor = value.end;
});
return { query: out + query.slice(cursor), unresolved };
};
/** reads the query from q or from the old state and label params */
export const queryFromUrl = (url: URL): string => {
const q = url.searchParams.get("q");
if (q !== null) return q.trim();
const parts: string[] = [];
const state = url.searchParams.get("state");
parts.push(state && state in STATE_VALUES ? `is:${state}` : DEFAULT_QUERY);
for (const label of url.searchParams.getAll("label")) parts.push(`label:${quoteValue(label)}`);
return join(parts);
};
export const labelQuery = (name: string): string =>
join([DEFAULT_QUERY, `label:${quoteValue(name)}`]);
/** the list link for a label */
export const labelHref = (listHref: string, name: string): string =>
`${listHref}?q=${encodeURIComponent(labelQuery(name))}`;
export interface Suggestion {
/** the text in the list */
label: string;
/** the text that replaces the range */
insert: string;
hint?: string;
/** a key keeps the caret after the colon */
kind: "key" | "value";
}
export interface Suggestions {
from: number;
to: number;
items: Suggestion[];
}
export interface ValueOption {
value: string;
hint?: string;
}
export interface QueryDialect<K extends string = string> {
qualifiers: readonly K[];
hints: Readonly<Record<K, string>>;
values(key: K, ctx: SuggestContext): ValueOption[];
problem(key: K, value: string): string | null;
}
const KEY_HINTS: Record<QualifierKey, string> = {
is: "state",
label: "label name",
author: "handle or did",
sort: "order"
};
export interface SuggestContext {
labels?: readonly string[];
isPull?: boolean;
}
export const SORT_OPTIONS: ValueOption[] = [
{ value: "newest", hint: "created, newest first" },
{ value: "oldest", hint: "created, oldest first" },
{ value: "relevance", hint: "best match first" }
];
const valuesFor = (key: QualifierKey, ctx: SuggestContext): ValueOption[] => {
const states = ctx.isPull
? [{ value: "open" }, { value: "merged" }, { value: "closed" }]
: [{ value: "open" }, { value: "closed" }];
switch (key) {
case "is":
return states;
case "label":
return (ctx.labels ?? []).map((value) => ({ value }));
case "sort":
return SORT_OPTIONS;
case "author":
return [];
}
};
const MAX_SUGGESTIONS = 8;
export const TICKET_DIALECT: QueryDialect<QualifierKey> = {
qualifiers: QUALIFIERS,
hints: KEY_HINTS,
values: valuesFor,
problem: valueProblem
};
/** the suggestions for the token at the caret */
export const suggest = (
query: string,
caret: number,
ctx: SuggestContext = {},
dialect: QueryDialect = TICKET_DIALECT
): Suggestions | null => {
const keyItem = (key: string): Suggestion => ({
label: `${key}:`,
insert: `${key}:`,
hint: dialect.hints[key],
kind: "key"
});
const token = tokenize(query, dialect.qualifiers).find(
(t) => t.start <= caret && caret <= t.end
);
if (!token || token.kind === "phrase") {
if (token) return null;
return { from: caret, to: caret, items: dialect.qualifiers.map(keyItem) };
}
const body = token.negated ? token.start + 1 : token.start;
if (token.kind === "word") {
const prefix = query.slice(body, caret).toLowerCase();
if (prefix === "" || !/^[a-z_]+$/.test(prefix)) return null;
const items = dialect.qualifiers.filter((key) => key.startsWith(prefix)).map(keyItem);
return items.length > 0 ? { from: body, to: token.end, items } : null;
}
if (!token.known || caret < token.keyEnd) return null;
const value = token.values.find((v) => v.start <= caret && caret <= v.end);
if (!value || value.quoted) return null;
const prefix = query.slice(value.start, caret).toLowerCase();
const taken = new Set(token.values.filter((v) => v !== value).map((v) => v.text.toLowerCase()));
const items = dialect
.values(token.key, ctx)
.filter(
({ value: v }) =>
v.toLowerCase().startsWith(prefix) &&
v.toLowerCase() !== prefix &&
!taken.has(v.toLowerCase())
)
.slice(0, MAX_SUGGESTIONS)
.map(({ value: v, hint }) => ({
label: v,
insert: quoteValue(v),
hint,
kind: "value" as const
}));
return items.length > 0 ? { from: value.start, to: value.end, items } : null;
};
/** applies a suggestion and returns the new text and caret */
export const accept = (
query: string,
suggestions: Suggestions,
item: Suggestion
): { query: string; caret: number } => {
const before = query.slice(0, suggestions.from);
let after = query.slice(suggestions.to);
let insert = item.insert;
if (item.kind === "value") {
if (after === "") insert += " ";
else if (!/^[\s,]/.test(after)) after = ` ${after}`;
}
return { query: before + insert + after, caret: before.length + insert.length };
};