mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-04 12:01:52 +02:00
Merge branch 'main' into codex/commercial-use-banners
This commit is contained in:
@@ -15,8 +15,9 @@ export interface FilterOptions {
|
||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||
}
|
||||
|
||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
||||
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const VALUE_IDENT = /\S+$/;
|
||||
const VALUE_IDENT_ONLY = /^\S+$/;
|
||||
|
||||
function normalizeFields(fields: FieldDef[]): {
|
||||
fieldNames: string[];
|
||||
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
|
||||
return { fieldNames, fieldMap };
|
||||
}
|
||||
|
||||
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
||||
function wordBefore(
|
||||
doc: string,
|
||||
pos: number,
|
||||
pattern: RegExp,
|
||||
): { from: number; to: number; text: string } | null {
|
||||
const upto = doc.slice(0, pos);
|
||||
const m = upto.match(IDENT);
|
||||
const m = upto.match(pattern);
|
||||
if (!m) return null;
|
||||
const from = pos - m[0].length;
|
||||
return { from, to: pos, text: m[0] };
|
||||
}
|
||||
|
||||
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
|
||||
const w = wordBefore(doc, pos, FIELD_IDENT);
|
||||
const from = w?.from ?? pos;
|
||||
const beforeToken = doc[from - 1];
|
||||
|
||||
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
|
||||
return { from, includeAt: true };
|
||||
}
|
||||
|
||||
if (beforeToken === "@") {
|
||||
const beforeAt = doc[from - 2];
|
||||
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
|
||||
return { from, includeAt: false };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inPhrase(ctx: CompletionContext): boolean {
|
||||
// Lezer node names from your grammar: Phrase is the quoted token
|
||||
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
|
||||
if (inValue) {
|
||||
// word before the colon = field name
|
||||
const beforeColon = stateDoc.slice(0, lastColon);
|
||||
const m = beforeColon.match(IDENT);
|
||||
const m = beforeColon.match(FIELD_IDENT);
|
||||
fieldName = m ? m[0] : null;
|
||||
|
||||
// nothing (or only spaces) typed after the colon?
|
||||
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
|
||||
}
|
||||
|
||||
/** Build a completion list for field names */
|
||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
||||
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
|
||||
return fieldNames.map((name) => ({
|
||||
label: name,
|
||||
type: "property",
|
||||
apply: (view, _completion, from, to) => {
|
||||
// Insert "name:" (leave cursor right after colon)
|
||||
// Leave cursor right after the field filter colon.
|
||||
const insert = `${includeAt ? "@" : ""}${name}:`;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `${name}:` },
|
||||
selection: { anchor: from + name.length + 1 },
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
@@ -115,7 +140,7 @@ function fieldValueCompletions(
|
||||
if (!def || !def.values) return null;
|
||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||
return vals.map((v) => ({
|
||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
||||
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
|
||||
displayLabel: v,
|
||||
type: "constant",
|
||||
}));
|
||||
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const w = wordBefore(doc, pos);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
|
||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||
|
||||
// In field value position
|
||||
if (inValue && fieldName) {
|
||||
const w = wordBefore(doc, pos, VALUE_IDENT);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
const valDefs = fieldMap[fieldName];
|
||||
const vals = fieldValueCompletions(valDefs);
|
||||
|
||||
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
}
|
||||
|
||||
// Not in a value: suggest field names (and maybe boolean ops)
|
||||
const options: Completion[] = fieldNameCompletions(fieldNames);
|
||||
const completion = fieldCompletionFrom(doc, pos);
|
||||
if (completion == null) return null;
|
||||
const { from, includeAt } = completion;
|
||||
const to = pos;
|
||||
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
|
||||
|
||||
return { from, to, options, filter: true };
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
@skip { space+ }
|
||||
@tokens {
|
||||
space { std.whitespace+ }
|
||||
space { $[ \t\r\n]+ }
|
||||
|
||||
LParen { "(" }
|
||||
RParen { ")" }
|
||||
At { "@" }
|
||||
Colon { ":" }
|
||||
Not { "-" | "NOT" }
|
||||
|
||||
@@ -16,8 +17,10 @@
|
||||
// "quoted phrase" with simple escapes: \" and \\
|
||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||
|
||||
// field/word characters (keep generous for URLs/paths)
|
||||
Word { $[A-Za-z0-9_]+ }
|
||||
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
|
||||
// negation, but '-' may appear after the first character.
|
||||
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
|
||||
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
|
||||
|
||||
@precedence { Not, And, Or, Word }
|
||||
}
|
||||
@@ -60,12 +63,12 @@ Field {
|
||||
}
|
||||
|
||||
FieldName {
|
||||
Word
|
||||
At? Word
|
||||
}
|
||||
|
||||
FieldValue {
|
||||
Phrase
|
||||
| Term
|
||||
| FieldValueWord
|
||||
}
|
||||
|
||||
Term {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parser } from "./filter";
|
||||
|
||||
function getNodeNames(input: string): string[] {
|
||||
const tree = parser.parse(input);
|
||||
const nodes: string[] = [];
|
||||
const cursor = tree.cursor();
|
||||
do {
|
||||
if (cursor.name !== "Query") {
|
||||
nodes.push(cursor.name);
|
||||
}
|
||||
} while (cursor.next());
|
||||
return nodes;
|
||||
}
|
||||
|
||||
describe("filter grammar", () => {
|
||||
test("parses URL-like field values as one value", () => {
|
||||
const nodes = getNodeNames("@url:yaak.app/foo-bar");
|
||||
|
||||
expect(nodes).not.toContain("⚠");
|
||||
expect(nodes).toContain("FieldValue");
|
||||
expect(nodes).toContain("FieldValueWord");
|
||||
});
|
||||
|
||||
test("parses punctuation-heavy field values as one value", () => {
|
||||
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
|
||||
|
||||
expect(nodes).not.toContain("⚠");
|
||||
expect(nodes).toContain("FieldValue");
|
||||
expect(nodes).toContain("FieldValueWord");
|
||||
});
|
||||
|
||||
test("parses operator-looking field values as one value", () => {
|
||||
const negativeValueNodes = getNodeNames("@url:-foo");
|
||||
const operatorWordNodes = getNodeNames("@url:AND");
|
||||
|
||||
expect(negativeValueNodes).not.toContain("⚠");
|
||||
expect(negativeValueNodes).toContain("FieldValueWord");
|
||||
expect(operatorWordNodes).not.toContain("⚠");
|
||||
expect(operatorWordNodes).toContain("FieldValueWord");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,22 @@
|
||||
/* oxlint-disable */
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from "@lezer/lr";
|
||||
import { highlight } from "./highlight";
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
||||
stateData:
|
||||
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
|
||||
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
|
||||
nodeNames:
|
||||
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
||||
maxTerm: 25,
|
||||
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
|
||||
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
|
||||
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
|
||||
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
|
||||
maxTerm: 27,
|
||||
nodeProps: [
|
||||
["openedBy", 8, "LParen"],
|
||||
["closedBy", 9, "RParen"],
|
||||
["openedBy", 8,"LParen"],
|
||||
["closedBy", 9,"RParen"]
|
||||
],
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0, 20],
|
||||
skippedNodes: [0,22],
|
||||
repeatNodeCount: 3,
|
||||
tokenData:
|
||||
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
||||
tokenizers: [0],
|
||||
topRules: { Query: [0, 1] },
|
||||
tokenPrec: 145,
|
||||
});
|
||||
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"Query":[0,1]},
|
||||
tokenPrec: 145
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { formatFieldFilter } from "./format";
|
||||
import { evaluate, parseQuery } from "./query";
|
||||
|
||||
function matchesFormattedUrl(value: string) {
|
||||
return evaluate(parseQuery(formatFieldFilter("url", value)), {
|
||||
fields: { url: value },
|
||||
});
|
||||
}
|
||||
|
||||
describe("formatFieldFilter", () => {
|
||||
test("keeps URL-like values bare", () => {
|
||||
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
|
||||
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps non-syntax punctuation bare", () => {
|
||||
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
|
||||
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps values that start with an operator token bare", () => {
|
||||
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
|
||||
expect(matchesFormattedUrl("-foo")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps boolean operator words bare", () => {
|
||||
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
|
||||
expect(formatFieldFilter("url", "or")).toBe("@url:or");
|
||||
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
|
||||
expect(matchesFormattedUrl("AND")).toBe(true);
|
||||
});
|
||||
|
||||
test("escapes quoted values", () => {
|
||||
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
|
||||
expect(matchesFormattedUrl('say "hi"')).toBe(true);
|
||||
});
|
||||
|
||||
test("quotes values that start with a quote", () => {
|
||||
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
|
||||
expect(matchesFormattedUrl('"hi"')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const bareFieldValue = /^[^\s"]\S*$/;
|
||||
|
||||
export function formatFieldFilter(field: string, value: string) {
|
||||
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
|
||||
return `@${field}:${filterValue}`;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const highlight = styleTags({
|
||||
Phrase: t.string, // "quoted string"
|
||||
|
||||
// Fields
|
||||
"FieldName/At": t.attributeName,
|
||||
"FieldName/Word": t.attributeName,
|
||||
"FieldValue/Term/Word": t.attributeValue,
|
||||
"FieldValue/FieldValueWord": t.attributeValue,
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ type Tok =
|
||||
| { kind: "EOF" };
|
||||
|
||||
const isSpace = (c: string) => /\s/.test(c);
|
||||
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
||||
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
|
||||
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
|
||||
|
||||
export function tokenize(input: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
|
||||
|
||||
const readWord = () => {
|
||||
let s = "";
|
||||
while (i < n && isIdent(peek())) s += advance();
|
||||
while (i < n && isWordChar(peek())) s += advance();
|
||||
return s;
|
||||
};
|
||||
|
||||
const readFieldValue = () => {
|
||||
let s = "";
|
||||
while (i < n && !isSpace(peek())) s += advance();
|
||||
return s;
|
||||
};
|
||||
|
||||
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
|
||||
if (c === ":") {
|
||||
toks.push({ kind: "COLON" });
|
||||
i++;
|
||||
if (peek() && !isSpace(peek()) && peek() !== `"`) {
|
||||
toks.push({ kind: "WORD", text: readFieldValue() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === `"`) {
|
||||
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
|
||||
}
|
||||
|
||||
// WORD / AND / OR / NOT
|
||||
if (isIdent(c)) {
|
||||
if (isWordStart(c)) {
|
||||
const w = readWord();
|
||||
const upper = w.toUpperCase();
|
||||
if (upper === "AND") toks.push({ kind: "AND" });
|
||||
|
||||
Reference in New Issue
Block a user