@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.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. 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
+ );