@mepuka/skygent 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
|
@@ -0,0 +1,1411 @@
|
|
|
1
|
+
import { Context, Duration, Effect, Schema } from "effect";
|
|
2
|
+
import { formatSchemaError } from "./shared.js";
|
|
3
|
+
import type { FilterEngagement, FilterExpr } from "../domain/filter.js";
|
|
4
|
+
import { all, and, none, not, or } from "../domain/filter.js";
|
|
5
|
+
import type { FilterErrorPolicy } from "../domain/policies.js";
|
|
6
|
+
import { ExcludeOnError, IncludeOnError, RetryOnError } from "../domain/policies.js";
|
|
7
|
+
import { Handle, Hashtag, StoreName } from "../domain/primitives.js";
|
|
8
|
+
import { FilterLibrary } from "../services/filter-library.js";
|
|
9
|
+
import { FilterLibraryError, FilterNotFound } from "../domain/errors.js";
|
|
10
|
+
import { CliInputError } from "./errors.js";
|
|
11
|
+
import { parseRange } from "./range.js";
|
|
12
|
+
|
|
13
|
+
type Token =
|
|
14
|
+
| { readonly _tag: "Word"; readonly value: string; readonly position: number }
|
|
15
|
+
| { readonly _tag: "LParen"; readonly position: number }
|
|
16
|
+
| { readonly _tag: "RParen"; readonly position: number }
|
|
17
|
+
| { readonly _tag: "And"; readonly position: number }
|
|
18
|
+
| { readonly _tag: "Or"; readonly position: number }
|
|
19
|
+
| { readonly _tag: "Not"; readonly position: number };
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type FilterLibraryService = Context.Tag.Service<typeof FilterLibrary>;
|
|
23
|
+
|
|
24
|
+
const formatDslError = (input: string, position: number | undefined, message: string) => {
|
|
25
|
+
if (position === undefined) {
|
|
26
|
+
return message;
|
|
27
|
+
}
|
|
28
|
+
const caret = `${" ".repeat(Math.max(0, position))}^`;
|
|
29
|
+
return `${message}\n${input}\n${caret}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const failAt = (input: string, position: number | undefined, message: string) =>
|
|
33
|
+
CliInputError.make({
|
|
34
|
+
message: formatDslError(input, position, message),
|
|
35
|
+
cause: { input, position, message }
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const isWhitespace = (char: string) => /\s/.test(char);
|
|
39
|
+
|
|
40
|
+
const tokenize = (input: string): Effect.Effect<ReadonlyArray<Token>, CliInputError> =>
|
|
41
|
+
Effect.suspend(() => {
|
|
42
|
+
const tokens: Array<Token> = [];
|
|
43
|
+
let pendingRegexValue = false;
|
|
44
|
+
let index = 0;
|
|
45
|
+
const length = input.length;
|
|
46
|
+
|
|
47
|
+
const pushWord = (value: string, position: number) => {
|
|
48
|
+
const upper = value.toUpperCase();
|
|
49
|
+
if (upper === "AND") {
|
|
50
|
+
tokens.push({ _tag: "And", position });
|
|
51
|
+
pendingRegexValue = false;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (upper === "OR") {
|
|
55
|
+
tokens.push({ _tag: "Or", position });
|
|
56
|
+
pendingRegexValue = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (upper === "NOT") {
|
|
60
|
+
tokens.push({ _tag: "Not", position });
|
|
61
|
+
pendingRegexValue = false;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
tokens.push({ _tag: "Word", value, position });
|
|
65
|
+
pendingRegexValue = value.toLowerCase() === "regex:";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
while (index < length) {
|
|
69
|
+
const char = input[index];
|
|
70
|
+
if (char === undefined) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (isWhitespace(char)) {
|
|
74
|
+
index += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const regexValueExpected = pendingRegexValue;
|
|
79
|
+
pendingRegexValue = false;
|
|
80
|
+
|
|
81
|
+
if (char === "(") {
|
|
82
|
+
tokens.push({ _tag: "LParen", position: index });
|
|
83
|
+
index += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === ")") {
|
|
87
|
+
tokens.push({ _tag: "RParen", position: index });
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (char === "!") {
|
|
92
|
+
tokens.push({ _tag: "Not", position: index });
|
|
93
|
+
index += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === "&") {
|
|
97
|
+
if (input.slice(index, index + 2) !== "&&") {
|
|
98
|
+
return Effect.fail(failAt(input, index, "Unexpected '&'. Use '&&' or AND."));
|
|
99
|
+
}
|
|
100
|
+
tokens.push({ _tag: "And", position: index });
|
|
101
|
+
index += 2;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (char === "|") {
|
|
105
|
+
if (input.slice(index, index + 2) !== "||") {
|
|
106
|
+
return Effect.fail(failAt(input, index, "Unexpected '|'. Use '||' or OR."));
|
|
107
|
+
}
|
|
108
|
+
tokens.push({ _tag: "Or", position: index });
|
|
109
|
+
index += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const start = index;
|
|
114
|
+
let word = "";
|
|
115
|
+
let inQuotes = false;
|
|
116
|
+
let quoteChar: string | null = null;
|
|
117
|
+
let quoteStart = -1;
|
|
118
|
+
const hasRegexPrefix =
|
|
119
|
+
input.slice(start, start + 6).toLowerCase() === "regex:";
|
|
120
|
+
let inRegexLiteral = regexValueExpected && input[start] === "/";
|
|
121
|
+
let regexLiteralStartIndex = inRegexLiteral ? start : -1;
|
|
122
|
+
|
|
123
|
+
while (index < length) {
|
|
124
|
+
const current = input[index];
|
|
125
|
+
if (current === undefined) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
!inRegexLiteral &&
|
|
130
|
+
(current === "\"" || current === "'") &&
|
|
131
|
+
input[index - 1] !== "\\"
|
|
132
|
+
) {
|
|
133
|
+
if (!inQuotes) {
|
|
134
|
+
inQuotes = true;
|
|
135
|
+
quoteChar = current;
|
|
136
|
+
quoteStart = index;
|
|
137
|
+
} else if (quoteChar === current) {
|
|
138
|
+
inQuotes = false;
|
|
139
|
+
quoteChar = null;
|
|
140
|
+
quoteStart = -1;
|
|
141
|
+
}
|
|
142
|
+
word += current;
|
|
143
|
+
index += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!inQuotes) {
|
|
147
|
+
if (
|
|
148
|
+
(hasRegexPrefix || inRegexLiteral) &&
|
|
149
|
+
current === "/" &&
|
|
150
|
+
input[index - 1] !== "\\"
|
|
151
|
+
) {
|
|
152
|
+
if (!inRegexLiteral) {
|
|
153
|
+
if (hasRegexPrefix && index >= start + 6) {
|
|
154
|
+
inRegexLiteral = true;
|
|
155
|
+
regexLiteralStartIndex = index;
|
|
156
|
+
}
|
|
157
|
+
} else if (index !== regexLiteralStartIndex) {
|
|
158
|
+
inRegexLiteral = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
!inQuotes &&
|
|
164
|
+
!inRegexLiteral &&
|
|
165
|
+
(isWhitespace(current) || current === "(" || current === ")")
|
|
166
|
+
) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
word += current;
|
|
170
|
+
index += 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (inQuotes) {
|
|
174
|
+
return Effect.fail(
|
|
175
|
+
failAt(input, quoteStart >= 0 ? quoteStart : start, "Unterminated quote.")
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (word.length === 0) {
|
|
180
|
+
return Effect.fail(failAt(input, start, "Unexpected token."));
|
|
181
|
+
}
|
|
182
|
+
pushWord(word, start);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Effect.succeed(tokens);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const stripQuotes = (value: string) => {
|
|
189
|
+
const trimmed = value.trim();
|
|
190
|
+
if (trimmed.length >= 2) {
|
|
191
|
+
const first = trimmed[0];
|
|
192
|
+
const last = trimmed[trimmed.length - 1];
|
|
193
|
+
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
194
|
+
return trimmed
|
|
195
|
+
.slice(1, -1)
|
|
196
|
+
.replace(/\\(["'\\])/g, "$1");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return trimmed;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
type OptionValue = {
|
|
203
|
+
readonly key: string;
|
|
204
|
+
readonly rawKey: string;
|
|
205
|
+
readonly value: string;
|
|
206
|
+
readonly position: number;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const normalizeOptionKey = (key: string) =>
|
|
210
|
+
key.trim().toLowerCase().replace(/[-_]/g, "");
|
|
211
|
+
|
|
212
|
+
const normalizeFilterKey = (key: string) => key.trim().toLowerCase();
|
|
213
|
+
|
|
214
|
+
const unsupportedFilterKeys = new Map<string, string>([
|
|
215
|
+
["label", "Label filters are not supported yet."]
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const filterKeyHints = new Map<string, string>([
|
|
219
|
+
["author", "author:handle"],
|
|
220
|
+
["from", "author:handle"],
|
|
221
|
+
["hashtag", "hashtag:#ai"],
|
|
222
|
+
["tag", "hashtag:#ai"],
|
|
223
|
+
["text", "text:\"hello\""],
|
|
224
|
+
["contains", "text:\"hello\""],
|
|
225
|
+
["regex", "regex:/hello/i"],
|
|
226
|
+
["date", "date:2024-01-01..2024-01-31"],
|
|
227
|
+
["range", "date:2024-01-01..2024-01-31"],
|
|
228
|
+
["daterange", "date:2024-01-01..2024-01-31"],
|
|
229
|
+
["trending", "trending:#ai"],
|
|
230
|
+
["trend", "trending:#ai"],
|
|
231
|
+
["language", "language:en"],
|
|
232
|
+
["lang", "language:en"],
|
|
233
|
+
["authorin", "authorin:alice,bob"],
|
|
234
|
+
["authors", "authorin:alice,bob"],
|
|
235
|
+
["hashtagin", "hashtagin:#ai,#ml"],
|
|
236
|
+
["tags", "hashtagin:#ai,#ml"],
|
|
237
|
+
["hashtags", "hashtagin:#ai,#ml"],
|
|
238
|
+
["engagement", "engagement:minLikes=100"]
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
type FilterSuggestion = {
|
|
242
|
+
readonly keys: ReadonlyArray<string>;
|
|
243
|
+
readonly suggestions: ReadonlyArray<string>;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const filterSuggestions: ReadonlyArray<FilterSuggestion> = [
|
|
247
|
+
{
|
|
248
|
+
keys: ["author", "from"],
|
|
249
|
+
suggestions: ["author:handle"]
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
keys: ["hashtag", "tag", "hashtags"],
|
|
253
|
+
suggestions: ["hashtag:#ai"]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
keys: ["contains", "text"],
|
|
257
|
+
suggestions: ["text:\"hello\""]
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
keys: ["regex"],
|
|
261
|
+
suggestions: ["regex:/hello/i"]
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
keys: ["date", "range", "daterange"],
|
|
265
|
+
suggestions: ["date:2024-01-01..2024-01-31"]
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
keys: ["has", "hasimage", "hasimages", "image", "images"],
|
|
269
|
+
suggestions: ["has:images"]
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
keys: ["hasvideo", "hasvideos", "video", "videos"],
|
|
273
|
+
suggestions: ["has:video"]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
keys: ["haslinks", "links", "link"],
|
|
277
|
+
suggestions: ["has:links"]
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
keys: ["hasmedia", "media"],
|
|
281
|
+
suggestions: ["has:media"]
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
keys: ["hasembed", "embed", "embeds"],
|
|
285
|
+
suggestions: ["has:embed"]
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
keys: ["language", "lang"],
|
|
289
|
+
suggestions: ["language:en"]
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
keys: ["trending", "trend"],
|
|
293
|
+
suggestions: ["trending:#ai"]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
keys: ["engagement"],
|
|
297
|
+
suggestions: ["engagement:minLikes=100"]
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
keys: ["authorin", "authors"],
|
|
301
|
+
suggestions: ["authorin:alice,bob"]
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
keys: ["hashtagin", "tags"],
|
|
305
|
+
suggestions: ["hashtagin:#ai,#ml"]
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
keys: ["is", "type", "reply", "quote", "repost", "original"],
|
|
309
|
+
suggestions: ["is:reply"]
|
|
310
|
+
}
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const defaultFilterExamples = [
|
|
314
|
+
"author:handle",
|
|
315
|
+
"hashtag:#ai",
|
|
316
|
+
"text:\"hello\""
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const uniqueSuggestions = (items: ReadonlyArray<string>) =>
|
|
320
|
+
Array.from(new Set(items));
|
|
321
|
+
|
|
322
|
+
const editDistance = (left: string, right: string) => {
|
|
323
|
+
const a = normalizeFilterKey(left);
|
|
324
|
+
const b = normalizeFilterKey(right);
|
|
325
|
+
const aLen = a.length;
|
|
326
|
+
const bLen = b.length;
|
|
327
|
+
if (aLen === 0) {
|
|
328
|
+
return bLen;
|
|
329
|
+
}
|
|
330
|
+
if (bLen === 0) {
|
|
331
|
+
return aLen;
|
|
332
|
+
}
|
|
333
|
+
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
|
|
334
|
+
for (let i = 0; i < aLen; i += 1) {
|
|
335
|
+
let current = i + 1;
|
|
336
|
+
const prevRow = prev.slice();
|
|
337
|
+
prev[0] = current;
|
|
338
|
+
for (let j = 0; j < bLen; j += 1) {
|
|
339
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
340
|
+
const insert = (prev[j] ?? 0) + 1;
|
|
341
|
+
const remove = (prevRow[j + 1] ?? 0) + 1;
|
|
342
|
+
const replace = (prevRow[j] ?? 0) + cost;
|
|
343
|
+
current = Math.min(insert, remove, replace);
|
|
344
|
+
prev[j + 1] = current;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return prev[bLen] ?? 0;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const findFilterSuggestions = (rawKey: string) => {
|
|
351
|
+
const key = normalizeFilterKey(rawKey);
|
|
352
|
+
const prefixMatches = filterSuggestions.filter((entry) =>
|
|
353
|
+
entry.keys.some((candidate) =>
|
|
354
|
+
candidate.startsWith(key) || key.startsWith(candidate)
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
if (prefixMatches.length > 0) {
|
|
358
|
+
return uniqueSuggestions(
|
|
359
|
+
prefixMatches.flatMap((entry) => entry.suggestions)
|
|
360
|
+
).slice(0, 3);
|
|
361
|
+
}
|
|
362
|
+
const scored = filterSuggestions
|
|
363
|
+
.map((entry) => ({
|
|
364
|
+
entry,
|
|
365
|
+
distance: Math.min(
|
|
366
|
+
...entry.keys.map((candidate) => editDistance(key, candidate))
|
|
367
|
+
)
|
|
368
|
+
}))
|
|
369
|
+
.filter((item) => item.distance <= 2)
|
|
370
|
+
.sort((a, b) => a.distance - b.distance);
|
|
371
|
+
if (scored.length === 0) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
return uniqueSuggestions(
|
|
375
|
+
scored.flatMap((item) => item.entry.suggestions)
|
|
376
|
+
).slice(0, 3);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const formatSuggestionHint = (suggestions: ReadonlyArray<string>) => {
|
|
380
|
+
if (suggestions.length === 0) {
|
|
381
|
+
return "";
|
|
382
|
+
}
|
|
383
|
+
if (suggestions.length === 1) {
|
|
384
|
+
return ` Did you mean "${suggestions[0]}"?`;
|
|
385
|
+
}
|
|
386
|
+
if (suggestions.length === 2) {
|
|
387
|
+
return ` Did you mean "${suggestions[0]}" or "${suggestions[1]}"?`;
|
|
388
|
+
}
|
|
389
|
+
return ` Did you mean "${suggestions[0]}", "${suggestions[1]}", or "${suggestions[2]}"?`;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const splitOptionSegments = (
|
|
393
|
+
raw: string,
|
|
394
|
+
position: number
|
|
395
|
+
): Array<{ readonly text: string; readonly position: number }> => {
|
|
396
|
+
const segments: Array<{ readonly text: string; readonly position: number }> = [];
|
|
397
|
+
let start = 0;
|
|
398
|
+
let inQuotes = false;
|
|
399
|
+
let quoteChar: string | null = null;
|
|
400
|
+
|
|
401
|
+
const pushSegment = (end: number) => {
|
|
402
|
+
const slice = raw.slice(start, end);
|
|
403
|
+
const trimmed = slice.trim();
|
|
404
|
+
const leading = slice.length - slice.trimStart().length;
|
|
405
|
+
segments.push({
|
|
406
|
+
text: trimmed,
|
|
407
|
+
position: position + start + leading
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
412
|
+
const char = raw[index];
|
|
413
|
+
if (char === "\\") {
|
|
414
|
+
index += 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (char === "\"" || char === "'") {
|
|
418
|
+
if (!inQuotes) {
|
|
419
|
+
inQuotes = true;
|
|
420
|
+
quoteChar = char;
|
|
421
|
+
} else if (quoteChar === char) {
|
|
422
|
+
inQuotes = false;
|
|
423
|
+
quoteChar = null;
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (char === "," && !inQuotes) {
|
|
428
|
+
pushSegment(index);
|
|
429
|
+
start = index + 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
pushSegment(raw.length);
|
|
434
|
+
return segments;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const splitRegexOptionSegments = (
|
|
438
|
+
raw: string,
|
|
439
|
+
position: number
|
|
440
|
+
): Array<{ readonly text: string; readonly position: number }> => {
|
|
441
|
+
const segments: Array<{ readonly text: string; readonly position: number }> = [];
|
|
442
|
+
let start = 0;
|
|
443
|
+
let inQuotes = false;
|
|
444
|
+
let quoteChar: string | null = null;
|
|
445
|
+
let inRegex = false;
|
|
446
|
+
|
|
447
|
+
const pushSegment = (end: number) => {
|
|
448
|
+
const slice = raw.slice(start, end);
|
|
449
|
+
const trimmed = slice.trim();
|
|
450
|
+
const leading = slice.length - slice.trimStart().length;
|
|
451
|
+
segments.push({
|
|
452
|
+
text: trimmed,
|
|
453
|
+
position: position + start + leading
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
458
|
+
const char = raw[index];
|
|
459
|
+
if (char === "\\") {
|
|
460
|
+
index += 1;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!inRegex && (char === "\"" || char === "'")) {
|
|
464
|
+
if (!inQuotes) {
|
|
465
|
+
inQuotes = true;
|
|
466
|
+
quoteChar = char;
|
|
467
|
+
} else if (quoteChar === char) {
|
|
468
|
+
inQuotes = false;
|
|
469
|
+
quoteChar = null;
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (!inQuotes && char === "/") {
|
|
474
|
+
inRegex = !inRegex;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (char === "," && !inQuotes && !inRegex) {
|
|
478
|
+
pushSegment(index);
|
|
479
|
+
start = index + 1;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
pushSegment(raw.length);
|
|
484
|
+
return segments;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const parseValueOptions = (
|
|
488
|
+
input: string,
|
|
489
|
+
raw: string,
|
|
490
|
+
position: number,
|
|
491
|
+
mode: "default" | "regex" = "default"
|
|
492
|
+
) =>
|
|
493
|
+
Effect.suspend(() => {
|
|
494
|
+
const segments =
|
|
495
|
+
mode === "regex"
|
|
496
|
+
? splitRegexOptionSegments(raw, position)
|
|
497
|
+
: splitOptionSegments(raw, position);
|
|
498
|
+
if (segments.length === 0) {
|
|
499
|
+
return Effect.succeed({ value: "", valuePosition: position, options: new Map() });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const base = segments[0] ?? { text: "", position };
|
|
503
|
+
const rest = segments.slice(1);
|
|
504
|
+
const options = new Map<string, OptionValue>();
|
|
505
|
+
|
|
506
|
+
for (const segment of rest) {
|
|
507
|
+
if (segment.text.length === 0) {
|
|
508
|
+
return Effect.fail(
|
|
509
|
+
failAt(input, segment.position, "Empty option segment. Use key=value.")
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const equalsIndex = segment.text.indexOf("=");
|
|
513
|
+
const colonIndex = segment.text.indexOf(":");
|
|
514
|
+
const separatorIndex = equalsIndex >= 0 ? equalsIndex : colonIndex;
|
|
515
|
+
if (separatorIndex === -1) {
|
|
516
|
+
return Effect.fail(
|
|
517
|
+
failAt(input, segment.position, "Options must be in key=value form.")
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const rawKey = segment.text.slice(0, separatorIndex).trim();
|
|
521
|
+
const rawValue = segment.text.slice(separatorIndex + 1).trim();
|
|
522
|
+
if (rawKey.length === 0) {
|
|
523
|
+
return Effect.fail(
|
|
524
|
+
failAt(input, segment.position, "Option key cannot be empty.")
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (rawValue.length === 0) {
|
|
528
|
+
return Effect.fail(
|
|
529
|
+
failAt(input, segment.position, `Option "${rawKey}" must have a value.`)
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const key = normalizeOptionKey(rawKey);
|
|
533
|
+
if (options.has(key)) {
|
|
534
|
+
return Effect.fail(
|
|
535
|
+
failAt(input, segment.position, `Duplicate option "${rawKey}".`)
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
options.set(key, { key, rawKey, value: rawValue, position: segment.position });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return Effect.succeed({
|
|
542
|
+
value: base.text,
|
|
543
|
+
valuePosition: base.position,
|
|
544
|
+
options
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const looksLikeOptionSegment = (raw: string) => {
|
|
549
|
+
const equalsIndex = raw.indexOf("=");
|
|
550
|
+
const colonIndex = raw.indexOf(":");
|
|
551
|
+
const separatorIndex = equalsIndex >= 0 ? equalsIndex : colonIndex;
|
|
552
|
+
if (separatorIndex <= 0) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const key = raw.slice(0, separatorIndex).trim();
|
|
556
|
+
const value = raw.slice(separatorIndex + 1).trim();
|
|
557
|
+
return key.length > 0 && value.length > 0;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const takeOption = (
|
|
561
|
+
options: Map<string, OptionValue>,
|
|
562
|
+
keys: ReadonlyArray<string>,
|
|
563
|
+
label: string,
|
|
564
|
+
input: string
|
|
565
|
+
) =>
|
|
566
|
+
Effect.suspend(() => {
|
|
567
|
+
const normalizedKeys = Array.from(
|
|
568
|
+
new Set(keys.map(normalizeOptionKey))
|
|
569
|
+
);
|
|
570
|
+
const matches = normalizedKeys
|
|
571
|
+
.map((key) => options.get(key))
|
|
572
|
+
.filter((value): value is OptionValue => value !== undefined);
|
|
573
|
+
|
|
574
|
+
if (matches.length > 1) {
|
|
575
|
+
const duplicate = matches[1]!;
|
|
576
|
+
return Effect.fail(
|
|
577
|
+
failAt(
|
|
578
|
+
input,
|
|
579
|
+
duplicate.position,
|
|
580
|
+
`Multiple "${label}" options specified.`
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const match = matches[0];
|
|
586
|
+
if (match) {
|
|
587
|
+
options.delete(match.key);
|
|
588
|
+
}
|
|
589
|
+
return Effect.succeed(match);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const ensureNoUnknownOptions = (
|
|
593
|
+
options: Map<string, OptionValue>,
|
|
594
|
+
input: string
|
|
595
|
+
) => {
|
|
596
|
+
if (options.size === 0) {
|
|
597
|
+
return Effect.void;
|
|
598
|
+
}
|
|
599
|
+
const first = options.values().next().value as OptionValue;
|
|
600
|
+
return Effect.fail(
|
|
601
|
+
failAt(input, first.position, `Unknown option "${first.rawKey}".`)
|
|
602
|
+
);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const parseNumberOption = (
|
|
606
|
+
option: OptionValue,
|
|
607
|
+
input: string,
|
|
608
|
+
label: string
|
|
609
|
+
) => {
|
|
610
|
+
const raw = stripQuotes(option.value);
|
|
611
|
+
const parsed = Number(raw);
|
|
612
|
+
if (!Number.isFinite(parsed)) {
|
|
613
|
+
return Effect.fail(
|
|
614
|
+
failAt(input, option.position, `${label} must be a number.`)
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return Effect.succeed(parsed);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const parseIntOption = (option: OptionValue, input: string, label: string) =>
|
|
621
|
+
Effect.gen(function* () {
|
|
622
|
+
const value = yield* parseNumberOption(option, input, label);
|
|
623
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
624
|
+
return yield* failAt(
|
|
625
|
+
input,
|
|
626
|
+
option.position,
|
|
627
|
+
`${label} must be a non-negative integer.`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return value;
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const parseDurationOption = (option: OptionValue, input: string, label: string) =>
|
|
634
|
+
Effect.try({
|
|
635
|
+
try: () => Duration.decode(stripQuotes(option.value) as Duration.DurationInput),
|
|
636
|
+
catch: () =>
|
|
637
|
+
failAt(
|
|
638
|
+
input,
|
|
639
|
+
option.position,
|
|
640
|
+
`Invalid ${label} duration. Use formats like "30 seconds" or "500 millis".`
|
|
641
|
+
)
|
|
642
|
+
}).pipe(
|
|
643
|
+
Effect.flatMap((duration) => {
|
|
644
|
+
if (!Duration.isFinite(duration)) {
|
|
645
|
+
return Effect.fail(
|
|
646
|
+
failAt(input, option.position, `${label} must be a finite duration.`)
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
if (Duration.toMillis(duration) < 0) {
|
|
650
|
+
return Effect.fail(
|
|
651
|
+
failAt(input, option.position, `${label} must be non-negative.`)
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return Effect.succeed(duration);
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const parseBooleanOption = (
|
|
659
|
+
option: OptionValue,
|
|
660
|
+
input: string,
|
|
661
|
+
label: string
|
|
662
|
+
) => {
|
|
663
|
+
const raw = stripQuotes(option.value).toLowerCase();
|
|
664
|
+
if (raw === "true") {
|
|
665
|
+
return Effect.succeed(true);
|
|
666
|
+
}
|
|
667
|
+
if (raw === "false") {
|
|
668
|
+
return Effect.succeed(false);
|
|
669
|
+
}
|
|
670
|
+
return Effect.fail(
|
|
671
|
+
failAt(input, option.position, `${label} must be true or false.`)
|
|
672
|
+
);
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const parseListValue = (
|
|
676
|
+
raw: string,
|
|
677
|
+
input: string,
|
|
678
|
+
position: number,
|
|
679
|
+
label: string
|
|
680
|
+
) =>
|
|
681
|
+
Effect.gen(function* () {
|
|
682
|
+
const trimmed = stripQuotes(raw).trim();
|
|
683
|
+
if (trimmed.length === 0) {
|
|
684
|
+
return yield* failAt(
|
|
685
|
+
input,
|
|
686
|
+
position,
|
|
687
|
+
`Missing value for "${label}".`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
const content =
|
|
691
|
+
trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
692
|
+
? trimmed.slice(1, -1)
|
|
693
|
+
: trimmed;
|
|
694
|
+
const items = content
|
|
695
|
+
.split(/[;,]/)
|
|
696
|
+
.map((item) => stripQuotes(item.trim()))
|
|
697
|
+
.filter((item) => item.length > 0);
|
|
698
|
+
if (items.length === 0) {
|
|
699
|
+
return yield* failAt(
|
|
700
|
+
input,
|
|
701
|
+
position,
|
|
702
|
+
`No values provided for "${label}".`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return items;
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const parsePolicy = (
|
|
709
|
+
options: Map<string, OptionValue>,
|
|
710
|
+
fallback: FilterErrorPolicy,
|
|
711
|
+
input: string,
|
|
712
|
+
position: number
|
|
713
|
+
) =>
|
|
714
|
+
Effect.gen(function* () {
|
|
715
|
+
const onError = yield* takeOption(options, ["onError"], "onError", input);
|
|
716
|
+
const maxRetries = yield* takeOption(
|
|
717
|
+
options,
|
|
718
|
+
["maxRetries", "retries"],
|
|
719
|
+
"maxRetries",
|
|
720
|
+
input
|
|
721
|
+
);
|
|
722
|
+
const baseDelay = yield* takeOption(
|
|
723
|
+
options,
|
|
724
|
+
["baseDelay", "delay"],
|
|
725
|
+
"baseDelay",
|
|
726
|
+
input
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
if (!onError && !maxRetries && !baseDelay) {
|
|
730
|
+
return fallback;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const mode = onError ? stripQuotes(onError.value).toLowerCase() : "retry";
|
|
734
|
+
|
|
735
|
+
switch (mode) {
|
|
736
|
+
case "include":
|
|
737
|
+
if (maxRetries || baseDelay) {
|
|
738
|
+
return yield* failAt(
|
|
739
|
+
input,
|
|
740
|
+
onError?.position ?? position,
|
|
741
|
+
"Retry options can only be used with onError=retry."
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return IncludeOnError.make({});
|
|
745
|
+
case "exclude":
|
|
746
|
+
if (maxRetries || baseDelay) {
|
|
747
|
+
return yield* failAt(
|
|
748
|
+
input,
|
|
749
|
+
onError?.position ?? position,
|
|
750
|
+
"Retry options can only be used with onError=retry."
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return ExcludeOnError.make({});
|
|
754
|
+
case "retry": {
|
|
755
|
+
if (!maxRetries) {
|
|
756
|
+
return yield* failAt(
|
|
757
|
+
input,
|
|
758
|
+
onError?.position ?? position,
|
|
759
|
+
"Retry policy requires maxRetries."
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (!baseDelay) {
|
|
763
|
+
return yield* failAt(
|
|
764
|
+
input,
|
|
765
|
+
onError?.position ?? position,
|
|
766
|
+
"Retry policy requires baseDelay."
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const retries = yield* parseIntOption(
|
|
770
|
+
maxRetries,
|
|
771
|
+
input,
|
|
772
|
+
"maxRetries"
|
|
773
|
+
);
|
|
774
|
+
const delay = yield* parseDurationOption(baseDelay, input, "baseDelay");
|
|
775
|
+
return RetryOnError.make({ maxRetries: retries, baseDelay: delay });
|
|
776
|
+
}
|
|
777
|
+
default:
|
|
778
|
+
return yield* failAt(
|
|
779
|
+
input,
|
|
780
|
+
onError?.position ?? position,
|
|
781
|
+
`Unknown onError policy "${mode}".`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const defaultLinksPolicy = () => ExcludeOnError.make({});
|
|
787
|
+
const defaultTrendingPolicy = () => IncludeOnError.make({});
|
|
788
|
+
|
|
789
|
+
const decodeHandle = (raw: string, source: string, position: number) =>
|
|
790
|
+
Schema.decodeUnknown(Handle)(raw).pipe(
|
|
791
|
+
Effect.mapError((error) =>
|
|
792
|
+
failAt(source, position, `Invalid handle: ${formatSchemaError(error)}`)
|
|
793
|
+
)
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const decodeHashtag = (raw: string, source: string, position: number) =>
|
|
797
|
+
Schema.decodeUnknown(Hashtag)(raw).pipe(
|
|
798
|
+
Effect.mapError((error) =>
|
|
799
|
+
failAt(source, position, `Invalid hashtag: ${formatSchemaError(error)}`)
|
|
800
|
+
)
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const parseRegexValue = (raw: string) => {
|
|
804
|
+
const trimmed = stripQuotes(raw);
|
|
805
|
+
if (trimmed.length === 0) {
|
|
806
|
+
return { pattern: "", flags: undefined };
|
|
807
|
+
}
|
|
808
|
+
if (trimmed.startsWith("/")) {
|
|
809
|
+
const lastSlash = trimmed.lastIndexOf("/");
|
|
810
|
+
if (lastSlash > 0) {
|
|
811
|
+
const pattern = trimmed.slice(1, lastSlash);
|
|
812
|
+
const flags = trimmed.slice(lastSlash + 1);
|
|
813
|
+
return { pattern, flags: flags.length > 0 ? flags : undefined };
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return { pattern: trimmed, flags: undefined };
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
class Parser {
|
|
820
|
+
private index = 0;
|
|
821
|
+
private readonly resolving = new Set<string>();
|
|
822
|
+
private static readonly maxNamedDepth = 50;
|
|
823
|
+
private static readonly maxParseDepth = 200;
|
|
824
|
+
private parseDepth = 0;
|
|
825
|
+
|
|
826
|
+
constructor(
|
|
827
|
+
private readonly input: string,
|
|
828
|
+
private readonly tokens: ReadonlyArray<Token>,
|
|
829
|
+
private readonly library: FilterLibraryService
|
|
830
|
+
) {}
|
|
831
|
+
|
|
832
|
+
parse = (): Effect.Effect<FilterExpr, CliInputError> => {
|
|
833
|
+
const self = this;
|
|
834
|
+
return Effect.gen(function* () {
|
|
835
|
+
if (self.tokens.length === 0) {
|
|
836
|
+
return yield* failAt(self.input, 0, "Empty filter expression.");
|
|
837
|
+
}
|
|
838
|
+
const expr = yield* self.parseOr();
|
|
839
|
+
const next = self.peek();
|
|
840
|
+
if (next) {
|
|
841
|
+
return yield* self.fail(`Unexpected token "${self.describe(next)}".`, next.position);
|
|
842
|
+
}
|
|
843
|
+
return expr;
|
|
844
|
+
});
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
private resolveNamedFilter(
|
|
848
|
+
raw: string,
|
|
849
|
+
position: number
|
|
850
|
+
): Effect.Effect<FilterExpr, CliInputError> {
|
|
851
|
+
const self = this;
|
|
852
|
+
return Effect.gen(function* () {
|
|
853
|
+
const nameRaw = raw.slice(1);
|
|
854
|
+
if (nameRaw.length === 0) {
|
|
855
|
+
return yield* self.fail("Named filter reference cannot be empty.", position);
|
|
856
|
+
}
|
|
857
|
+
const name = yield* Schema.decodeUnknown(StoreName)(nameRaw).pipe(
|
|
858
|
+
Effect.mapError((error) =>
|
|
859
|
+
failAt(
|
|
860
|
+
self.input,
|
|
861
|
+
position,
|
|
862
|
+
`Invalid filter name "${nameRaw}": ${formatSchemaError(error)}`
|
|
863
|
+
)
|
|
864
|
+
)
|
|
865
|
+
);
|
|
866
|
+
if (self.resolving.has(name)) {
|
|
867
|
+
return yield* self.fail(
|
|
868
|
+
`Cycle detected while resolving "@${nameRaw}".`,
|
|
869
|
+
position
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
if (self.resolving.size >= Parser.maxNamedDepth) {
|
|
873
|
+
return yield* self.fail(
|
|
874
|
+
`Named filter nesting exceeded ${Parser.maxNamedDepth} levels.`,
|
|
875
|
+
position
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
self.resolving.add(name);
|
|
879
|
+
const expr = yield* self.library.get(name).pipe(
|
|
880
|
+
Effect.mapError((error) => {
|
|
881
|
+
if (error instanceof FilterNotFound) {
|
|
882
|
+
return failAt(
|
|
883
|
+
self.input,
|
|
884
|
+
position,
|
|
885
|
+
`Unknown named filter "@${nameRaw}". Use "skygent filter list" to see available filters.`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
if (error instanceof FilterLibraryError) {
|
|
889
|
+
return failAt(
|
|
890
|
+
self.input,
|
|
891
|
+
position,
|
|
892
|
+
`Failed to load "@${nameRaw}": ${error.message}`
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
return failAt(
|
|
896
|
+
self.input,
|
|
897
|
+
position,
|
|
898
|
+
`Failed to load "@${nameRaw}": ${String(error)}`
|
|
899
|
+
);
|
|
900
|
+
}),
|
|
901
|
+
Effect.ensuring(
|
|
902
|
+
Effect.sync(() => {
|
|
903
|
+
self.resolving.delete(name);
|
|
904
|
+
})
|
|
905
|
+
)
|
|
906
|
+
);
|
|
907
|
+
return expr;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private parseOr(): Effect.Effect<FilterExpr, CliInputError> {
|
|
912
|
+
const self = this;
|
|
913
|
+
return Effect.gen(function* () {
|
|
914
|
+
let expr = yield* self.parseAnd();
|
|
915
|
+
while (self.match("Or")) {
|
|
916
|
+
const right = yield* self.parseAnd();
|
|
917
|
+
expr = or(expr, right);
|
|
918
|
+
}
|
|
919
|
+
return expr;
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private parseAnd(): Effect.Effect<FilterExpr, CliInputError> {
|
|
924
|
+
const self = this;
|
|
925
|
+
return Effect.gen(function* () {
|
|
926
|
+
let expr = yield* self.parseUnary();
|
|
927
|
+
while (self.match("And")) {
|
|
928
|
+
const right = yield* self.parseUnary();
|
|
929
|
+
expr = and(expr, right);
|
|
930
|
+
}
|
|
931
|
+
return expr;
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private parseUnary(): Effect.Effect<FilterExpr, CliInputError> {
|
|
936
|
+
const self = this;
|
|
937
|
+
return Effect.gen(function* () {
|
|
938
|
+
if (self.match("Not")) {
|
|
939
|
+
const expr = yield* self.parseUnary();
|
|
940
|
+
return not(expr);
|
|
941
|
+
}
|
|
942
|
+
return yield* self.parsePrimary();
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private parsePrimary(): Effect.Effect<FilterExpr, CliInputError> {
|
|
947
|
+
const self = this;
|
|
948
|
+
return Effect.gen(function* () {
|
|
949
|
+
const current = self.peek();
|
|
950
|
+
if (!current) {
|
|
951
|
+
return yield* self.fail("Unexpected end of input.", self.input.length);
|
|
952
|
+
}
|
|
953
|
+
if (current._tag === "LParen") {
|
|
954
|
+
self.advance();
|
|
955
|
+
if (self.parseDepth >= Parser.maxParseDepth) {
|
|
956
|
+
return yield* self.fail(
|
|
957
|
+
`Filter nesting exceeded ${Parser.maxParseDepth} levels.`,
|
|
958
|
+
current.position
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
self.parseDepth += 1;
|
|
962
|
+
const expr = yield* self
|
|
963
|
+
.parseOr()
|
|
964
|
+
.pipe(Effect.ensuring(Effect.sync(() => {
|
|
965
|
+
self.parseDepth -= 1;
|
|
966
|
+
})));
|
|
967
|
+
const closing = self.peek();
|
|
968
|
+
if (!closing || closing._tag !== "RParen") {
|
|
969
|
+
return yield* self.fail("Expected ')'.", self.input.length);
|
|
970
|
+
}
|
|
971
|
+
self.advance();
|
|
972
|
+
return expr;
|
|
973
|
+
}
|
|
974
|
+
if (current._tag === "Word") {
|
|
975
|
+
self.advance();
|
|
976
|
+
return yield* self.parseWord(current);
|
|
977
|
+
}
|
|
978
|
+
return yield* self.fail(`Unexpected token "${self.describe(current)}".`, current.position);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
private parseWord(
|
|
983
|
+
token: Extract<Token, { _tag: "Word" }>
|
|
984
|
+
): Effect.Effect<FilterExpr, CliInputError> {
|
|
985
|
+
const self = this;
|
|
986
|
+
return Effect.gen(function* () {
|
|
987
|
+
const value = token.value;
|
|
988
|
+
const lower = value.toLowerCase();
|
|
989
|
+
if (value.startsWith("@")) {
|
|
990
|
+
return yield* self.resolveNamedFilter(value, token.position);
|
|
991
|
+
}
|
|
992
|
+
if (lower === "all") {
|
|
993
|
+
return all();
|
|
994
|
+
}
|
|
995
|
+
if (lower === "none") {
|
|
996
|
+
return none();
|
|
997
|
+
}
|
|
998
|
+
if (lower === "reply" || lower === "isreply") {
|
|
999
|
+
return { _tag: "IsReply" };
|
|
1000
|
+
}
|
|
1001
|
+
if (lower === "quote" || lower === "isquote") {
|
|
1002
|
+
return { _tag: "IsQuote" };
|
|
1003
|
+
}
|
|
1004
|
+
if (lower === "repost" || lower === "isrepost") {
|
|
1005
|
+
return { _tag: "IsRepost" };
|
|
1006
|
+
}
|
|
1007
|
+
if (lower === "original" || lower === "isoriginal") {
|
|
1008
|
+
return { _tag: "IsOriginal" };
|
|
1009
|
+
}
|
|
1010
|
+
if (lower === "hasimages" || lower === "hasimage" || lower === "images" || lower === "image") {
|
|
1011
|
+
return { _tag: "HasImages" };
|
|
1012
|
+
}
|
|
1013
|
+
if (lower === "hasvideo" || lower === "hasvideos" || lower === "video" || lower === "videos") {
|
|
1014
|
+
return { _tag: "HasVideo" };
|
|
1015
|
+
}
|
|
1016
|
+
if (lower === "hasmedia" || lower === "media") {
|
|
1017
|
+
return { _tag: "HasMedia" };
|
|
1018
|
+
}
|
|
1019
|
+
if (lower === "hasembed" || lower === "embed" || lower === "embeds") {
|
|
1020
|
+
return { _tag: "HasEmbed" };
|
|
1021
|
+
}
|
|
1022
|
+
if (lower === "haslinks") {
|
|
1023
|
+
return { _tag: "HasLinks" };
|
|
1024
|
+
}
|
|
1025
|
+
if (lower === "links" || lower === "validlinks" || lower === "hasvalidlinks") {
|
|
1026
|
+
return { _tag: "HasValidLinks", onError: defaultLinksPolicy() };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const colonIndex = value.indexOf(":");
|
|
1030
|
+
if (colonIndex === -1) {
|
|
1031
|
+
const unsupported = unsupportedFilterKeys.get(lower);
|
|
1032
|
+
if (unsupported) {
|
|
1033
|
+
return yield* self.fail(
|
|
1034
|
+
`Unknown filter type "${value}". ${unsupported}`,
|
|
1035
|
+
token.position
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
const hint = filterKeyHints.get(lower);
|
|
1039
|
+
if (hint) {
|
|
1040
|
+
return yield* self.fail(
|
|
1041
|
+
`Missing ":" after "${value}". Try "${hint}".`,
|
|
1042
|
+
token.position
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
return yield* self.fail(
|
|
1046
|
+
"Expected a filter expression like 'hashtag:#ai' or 'author:handle'.",
|
|
1047
|
+
token.position
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const rawKey = value.slice(0, colonIndex);
|
|
1052
|
+
const key = rawKey.toLowerCase();
|
|
1053
|
+
let rawValue = value.slice(colonIndex + 1);
|
|
1054
|
+
let valuePosition = token.position + colonIndex + 1;
|
|
1055
|
+
if (rawValue.length === 0) {
|
|
1056
|
+
const next = self.peek();
|
|
1057
|
+
if (next && next._tag === "Word") {
|
|
1058
|
+
rawValue = next.value;
|
|
1059
|
+
valuePosition = next.position;
|
|
1060
|
+
self.advance();
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (key === "text" && normalizeFilterKey(rawValue) === "contains") {
|
|
1064
|
+
const next = self.peek();
|
|
1065
|
+
if (next && next._tag === "Word") {
|
|
1066
|
+
rawValue = next.value;
|
|
1067
|
+
valuePosition = next.position;
|
|
1068
|
+
self.advance();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (key === "authorin" || key === "authors") {
|
|
1073
|
+
const items = yield* parseListValue(
|
|
1074
|
+
rawValue,
|
|
1075
|
+
self.input,
|
|
1076
|
+
valuePosition,
|
|
1077
|
+
key
|
|
1078
|
+
);
|
|
1079
|
+
const handles = yield* Effect.forEach(
|
|
1080
|
+
items,
|
|
1081
|
+
(item) => decodeHandle(item, self.input, valuePosition),
|
|
1082
|
+
{ discard: false }
|
|
1083
|
+
);
|
|
1084
|
+
return { _tag: "AuthorIn", handles };
|
|
1085
|
+
}
|
|
1086
|
+
if (key === "hashtagin" || key === "tags" || key === "hashtags") {
|
|
1087
|
+
const items = yield* parseListValue(
|
|
1088
|
+
rawValue,
|
|
1089
|
+
self.input,
|
|
1090
|
+
valuePosition,
|
|
1091
|
+
key
|
|
1092
|
+
);
|
|
1093
|
+
const tags = yield* Effect.forEach(
|
|
1094
|
+
items,
|
|
1095
|
+
(item) => decodeHashtag(item, self.input, valuePosition),
|
|
1096
|
+
{ discard: false }
|
|
1097
|
+
);
|
|
1098
|
+
return { _tag: "HashtagIn", tags };
|
|
1099
|
+
}
|
|
1100
|
+
if (key === "language" || key === "lang") {
|
|
1101
|
+
const items = yield* parseListValue(
|
|
1102
|
+
rawValue,
|
|
1103
|
+
self.input,
|
|
1104
|
+
valuePosition,
|
|
1105
|
+
key
|
|
1106
|
+
);
|
|
1107
|
+
return { _tag: "Language", langs: items };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const optionMode = key === "regex" ? "regex" : "default";
|
|
1111
|
+
const { value: baseValueRaw, valuePosition: basePosition, options } =
|
|
1112
|
+
yield* parseValueOptions(self.input, rawValue, valuePosition, optionMode);
|
|
1113
|
+
const baseValue = stripQuotes(baseValueRaw);
|
|
1114
|
+
|
|
1115
|
+
switch (key) {
|
|
1116
|
+
case "author":
|
|
1117
|
+
case "from": {
|
|
1118
|
+
if (baseValue.length === 0) {
|
|
1119
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1120
|
+
}
|
|
1121
|
+
const handle = yield* decodeHandle(baseValue, self.input, basePosition);
|
|
1122
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1123
|
+
return { _tag: "Author", handle };
|
|
1124
|
+
}
|
|
1125
|
+
case "hashtag":
|
|
1126
|
+
case "tag": {
|
|
1127
|
+
if (baseValue.length === 0) {
|
|
1128
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1129
|
+
}
|
|
1130
|
+
const tag = yield* decodeHashtag(baseValue, self.input, basePosition);
|
|
1131
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1132
|
+
return { _tag: "Hashtag", tag };
|
|
1133
|
+
}
|
|
1134
|
+
case "contains":
|
|
1135
|
+
case "text": {
|
|
1136
|
+
if (baseValue.length === 0) {
|
|
1137
|
+
return yield* self.fail("Contains filter requires text.", token.position);
|
|
1138
|
+
}
|
|
1139
|
+
const caseSensitiveOption = yield* takeOption(
|
|
1140
|
+
options,
|
|
1141
|
+
["caseSensitive", "case", "cs"],
|
|
1142
|
+
"caseSensitive",
|
|
1143
|
+
self.input
|
|
1144
|
+
);
|
|
1145
|
+
const caseSensitive = caseSensitiveOption
|
|
1146
|
+
? yield* parseBooleanOption(
|
|
1147
|
+
caseSensitiveOption,
|
|
1148
|
+
self.input,
|
|
1149
|
+
"caseSensitive"
|
|
1150
|
+
)
|
|
1151
|
+
: undefined;
|
|
1152
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1153
|
+
return caseSensitive !== undefined
|
|
1154
|
+
? { _tag: "Contains", text: baseValue, caseSensitive }
|
|
1155
|
+
: { _tag: "Contains", text: baseValue };
|
|
1156
|
+
}
|
|
1157
|
+
case "is":
|
|
1158
|
+
case "type": {
|
|
1159
|
+
if (baseValue.length === 0) {
|
|
1160
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1161
|
+
}
|
|
1162
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1163
|
+
switch (baseValue.toLowerCase()) {
|
|
1164
|
+
case "reply":
|
|
1165
|
+
return { _tag: "IsReply" };
|
|
1166
|
+
case "quote":
|
|
1167
|
+
return { _tag: "IsQuote" };
|
|
1168
|
+
case "repost":
|
|
1169
|
+
return { _tag: "IsRepost" };
|
|
1170
|
+
case "original":
|
|
1171
|
+
return { _tag: "IsOriginal" };
|
|
1172
|
+
default:
|
|
1173
|
+
return yield* self.fail(
|
|
1174
|
+
`Unknown post type "${baseValue}".`,
|
|
1175
|
+
token.position
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
case "has": {
|
|
1180
|
+
if (baseValue.length === 0) {
|
|
1181
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1182
|
+
}
|
|
1183
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1184
|
+
switch (baseValue.toLowerCase()) {
|
|
1185
|
+
case "images":
|
|
1186
|
+
case "image":
|
|
1187
|
+
return { _tag: "HasImages" };
|
|
1188
|
+
case "video":
|
|
1189
|
+
case "videos":
|
|
1190
|
+
return { _tag: "HasVideo" };
|
|
1191
|
+
case "links":
|
|
1192
|
+
case "link":
|
|
1193
|
+
return { _tag: "HasLinks" };
|
|
1194
|
+
case "media":
|
|
1195
|
+
return { _tag: "HasMedia" };
|
|
1196
|
+
case "embed":
|
|
1197
|
+
case "embeds":
|
|
1198
|
+
return { _tag: "HasEmbed" };
|
|
1199
|
+
default:
|
|
1200
|
+
return yield* self.fail(`Unknown has: filter "${baseValue}".`, token.position);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
case "engagement": {
|
|
1204
|
+
let resolvedValue = baseValue;
|
|
1205
|
+
let resolvedOptions = options;
|
|
1206
|
+
if (rawValue.length > 0 && looksLikeOptionSegment(rawValue)) {
|
|
1207
|
+
const reparsed = yield* parseValueOptions(
|
|
1208
|
+
self.input,
|
|
1209
|
+
`,${rawValue}`,
|
|
1210
|
+
Math.max(0, valuePosition - 1)
|
|
1211
|
+
);
|
|
1212
|
+
resolvedValue = stripQuotes(reparsed.value);
|
|
1213
|
+
resolvedOptions = reparsed.options;
|
|
1214
|
+
}
|
|
1215
|
+
if (resolvedValue.length > 0) {
|
|
1216
|
+
return yield* self.fail(
|
|
1217
|
+
"Engagement does not take a positional value.",
|
|
1218
|
+
token.position
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
const minLikesOption = yield* takeOption(
|
|
1222
|
+
resolvedOptions,
|
|
1223
|
+
["minLikes", "likes", "minlikes"],
|
|
1224
|
+
"minLikes",
|
|
1225
|
+
self.input
|
|
1226
|
+
);
|
|
1227
|
+
const minRepostsOption = yield* takeOption(
|
|
1228
|
+
resolvedOptions,
|
|
1229
|
+
["minReposts", "reposts", "minreposts"],
|
|
1230
|
+
"minReposts",
|
|
1231
|
+
self.input
|
|
1232
|
+
);
|
|
1233
|
+
const minRepliesOption = yield* takeOption(
|
|
1234
|
+
resolvedOptions,
|
|
1235
|
+
["minReplies", "replies", "minreplies"],
|
|
1236
|
+
"minReplies",
|
|
1237
|
+
self.input
|
|
1238
|
+
);
|
|
1239
|
+
const minLikes = minLikesOption
|
|
1240
|
+
? yield* parseIntOption(minLikesOption, self.input, "minLikes")
|
|
1241
|
+
: undefined;
|
|
1242
|
+
const minReposts = minRepostsOption
|
|
1243
|
+
? yield* parseIntOption(minRepostsOption, self.input, "minReposts")
|
|
1244
|
+
: undefined;
|
|
1245
|
+
const minReplies = minRepliesOption
|
|
1246
|
+
? yield* parseIntOption(minRepliesOption, self.input, "minReplies")
|
|
1247
|
+
: undefined;
|
|
1248
|
+
if (
|
|
1249
|
+
minLikes === undefined &&
|
|
1250
|
+
minReposts === undefined &&
|
|
1251
|
+
minReplies === undefined
|
|
1252
|
+
) {
|
|
1253
|
+
return yield* self.fail(
|
|
1254
|
+
"Engagement requires at least one threshold.",
|
|
1255
|
+
token.position
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
yield* ensureNoUnknownOptions(resolvedOptions, self.input);
|
|
1259
|
+
const engagement: FilterEngagement = {
|
|
1260
|
+
_tag: "Engagement",
|
|
1261
|
+
...(minLikes !== undefined ? { minLikes } : {}),
|
|
1262
|
+
...(minReposts !== undefined ? { minReposts } : {}),
|
|
1263
|
+
...(minReplies !== undefined ? { minReplies } : {})
|
|
1264
|
+
};
|
|
1265
|
+
return engagement;
|
|
1266
|
+
}
|
|
1267
|
+
case "regex": {
|
|
1268
|
+
const flagsOption = yield* takeOption(options, ["flags"], "flags", self.input);
|
|
1269
|
+
const { pattern, flags } = parseRegexValue(baseValueRaw);
|
|
1270
|
+
if (pattern.length === 0) {
|
|
1271
|
+
return yield* self.fail("Regex pattern cannot be empty.", token.position);
|
|
1272
|
+
}
|
|
1273
|
+
if (flags && flagsOption) {
|
|
1274
|
+
return yield* self.fail("Regex flags specified twice.", flagsOption.position);
|
|
1275
|
+
}
|
|
1276
|
+
const optionFlags = flagsOption ? stripQuotes(flagsOption.value) : undefined;
|
|
1277
|
+
if (flagsOption && optionFlags !== undefined && optionFlags.length === 0) {
|
|
1278
|
+
return yield* self.fail("Regex flags cannot be empty.", flagsOption.position);
|
|
1279
|
+
}
|
|
1280
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1281
|
+
const base = { _tag: "Regex", patterns: [pattern] } as const;
|
|
1282
|
+
const resolvedFlags = optionFlags ?? flags;
|
|
1283
|
+
return resolvedFlags ? { ...base, flags: resolvedFlags } : base;
|
|
1284
|
+
}
|
|
1285
|
+
case "date":
|
|
1286
|
+
case "range":
|
|
1287
|
+
case "daterange": {
|
|
1288
|
+
if (baseValue.length === 0) {
|
|
1289
|
+
return yield* self.fail("Date range must be <start>..<end>.", token.position);
|
|
1290
|
+
}
|
|
1291
|
+
const range = yield* parseRange(baseValue).pipe(
|
|
1292
|
+
Effect.mapError((error) =>
|
|
1293
|
+
failAt(self.input, basePosition, error.message)
|
|
1294
|
+
)
|
|
1295
|
+
);
|
|
1296
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1297
|
+
return { _tag: "DateRange", start: range.start, end: range.end };
|
|
1298
|
+
}
|
|
1299
|
+
case "links":
|
|
1300
|
+
case "validlinks":
|
|
1301
|
+
case "hasvalidlinks": {
|
|
1302
|
+
let resolvedValue = baseValue;
|
|
1303
|
+
let resolvedOptions = options;
|
|
1304
|
+
if (rawValue.length > 0 && looksLikeOptionSegment(rawValue)) {
|
|
1305
|
+
const reparsed = yield* parseValueOptions(
|
|
1306
|
+
self.input,
|
|
1307
|
+
`,${rawValue}`,
|
|
1308
|
+
Math.max(0, valuePosition - 1)
|
|
1309
|
+
);
|
|
1310
|
+
resolvedValue = stripQuotes(reparsed.value);
|
|
1311
|
+
resolvedOptions = reparsed.options;
|
|
1312
|
+
}
|
|
1313
|
+
if (resolvedValue.length > 0) {
|
|
1314
|
+
return yield* self.fail(
|
|
1315
|
+
"HasValidLinks does not take a value.",
|
|
1316
|
+
token.position
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
const policy = yield* parsePolicy(
|
|
1320
|
+
resolvedOptions,
|
|
1321
|
+
defaultLinksPolicy(),
|
|
1322
|
+
self.input,
|
|
1323
|
+
token.position
|
|
1324
|
+
);
|
|
1325
|
+
yield* ensureNoUnknownOptions(resolvedOptions, self.input);
|
|
1326
|
+
return { _tag: "HasValidLinks", onError: policy };
|
|
1327
|
+
}
|
|
1328
|
+
case "trending":
|
|
1329
|
+
case "trend": {
|
|
1330
|
+
if (baseValue.length === 0) {
|
|
1331
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1332
|
+
}
|
|
1333
|
+
const tag = yield* decodeHashtag(baseValue, self.input, basePosition);
|
|
1334
|
+
const policy = yield* parsePolicy(
|
|
1335
|
+
options,
|
|
1336
|
+
defaultTrendingPolicy(),
|
|
1337
|
+
self.input,
|
|
1338
|
+
token.position
|
|
1339
|
+
);
|
|
1340
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1341
|
+
return { _tag: "Trending", tag, onError: policy };
|
|
1342
|
+
}
|
|
1343
|
+
default: {
|
|
1344
|
+
const unsupported = unsupportedFilterKeys.get(key);
|
|
1345
|
+
if (unsupported) {
|
|
1346
|
+
return yield* self.fail(
|
|
1347
|
+
`Unknown filter type "${rawKey}". ${unsupported}`,
|
|
1348
|
+
token.position
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
const suggestions = findFilterSuggestions(key);
|
|
1352
|
+
const hint = suggestions.length > 0
|
|
1353
|
+
? formatSuggestionHint(suggestions)
|
|
1354
|
+
: ` Try "${defaultFilterExamples[0]}", "${defaultFilterExamples[1]}", or "${defaultFilterExamples[2]}".`;
|
|
1355
|
+
return yield* self.fail(
|
|
1356
|
+
`Unknown filter type "${rawKey}".${hint}`,
|
|
1357
|
+
token.position
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
private fail(message: string, position: number): Effect.Effect<never, CliInputError> {
|
|
1365
|
+
return Effect.fail(failAt(this.input, position, message));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
private peek(): Token | undefined {
|
|
1369
|
+
return this.tokens[this.index];
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
private advance(): Token | undefined {
|
|
1373
|
+
const token = this.tokens[this.index];
|
|
1374
|
+
this.index += 1;
|
|
1375
|
+
return token;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
private match(tag: Token["_tag"]): boolean {
|
|
1379
|
+
const token = this.peek();
|
|
1380
|
+
if (token && token._tag === tag) {
|
|
1381
|
+
this.advance();
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
private describe(token: Token): string {
|
|
1388
|
+
switch (token._tag) {
|
|
1389
|
+
case "Word":
|
|
1390
|
+
return token.value;
|
|
1391
|
+
case "LParen":
|
|
1392
|
+
return "(";
|
|
1393
|
+
case "RParen":
|
|
1394
|
+
return ")";
|
|
1395
|
+
case "And":
|
|
1396
|
+
return "AND";
|
|
1397
|
+
case "Or":
|
|
1398
|
+
return "OR";
|
|
1399
|
+
case "Not":
|
|
1400
|
+
return "NOT";
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export const parseFilterDsl = Effect.fn("FilterDsl.parse")((input: string) =>
|
|
1406
|
+
Effect.gen(function* () {
|
|
1407
|
+
const library = yield* FilterLibrary;
|
|
1408
|
+
const tokens = yield* tokenize(input);
|
|
1409
|
+
return yield* new Parser(input, tokens, library).parse();
|
|
1410
|
+
})
|
|
1411
|
+
);
|