change bulk selection panel to allow more versatile input (#4394)

# Description of Changes

- Add features to BulkSelectionPanel to allow more versatility when
selecting pages
- Make changes to Tooltip to: Remove non-existent props delayAppearance,
fixed defaults no hardcoded maxWidth, and documented new props
(closeOnOutside, containerStyle, minWidth). Clarify pinned vs. unpinned
outside-click logic, hover/focus interactions, and event/ref
preservation.
- Made top controls show full text always rather than dynamically
display the text only for the selected items
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
EthanHealy01
2025-09-18 11:19:52 +01:00
committed by GitHub
parent 06e5205302
commit d2de8e54aa
22 changed files with 2323 additions and 441 deletions

View File

@@ -0,0 +1,77 @@
## Bulk Selection Expressions
### What this does
- Lets you select pages using compact expressions instead of typing long CSV lists.
- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count.
- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending.
### Basic forms
- Numbers: `5` selects page 5.
- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3``3-7`).
- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10.
You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work.
### Logical operators
- OR (union): `,` or `|` or the word `or`
- AND (intersection): `&` or the word `and`
- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)`
Operator precedence (from highest to lowest):
1) `!` (NOT)
2) `&` / `and` (AND)
3) `,` / `|` / `or` (OR)
Use parentheses `(...)` to override precedence where needed.
### Keywords and progressions
- Keywords (case-insensitive):
- `even`: all even pages (2, 4, 6, ...)
- `odd`: all odd pages (1, 3, 5, ...)
- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1`
- `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded.
- `k` must be a positive integer (≥ 1). `c` can be any integer (including negative).
- Examples:
- `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive
- `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd)
- `3n+1` → 1,4,7,10,13,...
All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`.
### Parentheses
- Group with parentheses to control evaluation order and combine NOT with groups.
- Examples:
- `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10)
- `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10
- `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20
- `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20
### Whitespace and case
- Whitespace is ignored: ` odd & 1 - 7` is valid.
- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work.
### Universe, clamping, deduplication
- The selection universe is the document's pages `[1..maxPages]`.
- Numbers outside the universe are discarded.
- Ranges are clamped to `[1..maxPages]` (e.g., `0-5``1-5`, `9-999` in a 10-page doc → `9-10`).
- Duplicates are removed; the final result is sorted ascending.
### Examples
- `1-10 & 2n & !5-7` → 2,4,8,10
- `odd` → 1,3,5,7,9,...
- `even` → 2,4,6,8,10,...
- `2n-1` → 1,3,5,7,9,...
- `3n+1` → 4,7,10,13,16,... (up to max pages)
- `1-3, 8-9` → 1,2,3,8,9
- `1-2 | 9-10 or 5` → 1,2,5,9,10
- `!(1-5)` → remove the first five pages from the universe
- `!(10-20 & !2n)` → complement of odd pages between 10 and 20

View File

@@ -0,0 +1,253 @@
import { describe, it, expect } from 'vitest';
import { parseSelection } from './parseSelection';
describe('parseSelection', () => {
const max = 120;
it('1) parses single numbers', () => {
expect(parseSelection('5', max)).toEqual([5]);
});
it('2) parses simple range', () => {
expect(parseSelection('3-7', max)).toEqual([3,4,5,6,7]);
});
it('3) parses multiple numbers and ranges via comma OR', () => {
expect(parseSelection('1,3-5,10', max)).toEqual([1,3,4,5,10]);
});
it('4) respects bounds (clamps to 1..max and filters invalid)', () => {
expect(parseSelection('0, -2, 1-2, 9999', max)).toEqual([1,2]);
});
it('5) supports even keyword', () => {
expect(parseSelection('even', 10)).toEqual([2,4,6,8,10]);
});
it('6) supports odd keyword', () => {
expect(parseSelection('odd', 10)).toEqual([1,3,5,7,9]);
});
it('7) supports 2n progression', () => {
expect(parseSelection('2n', 12)).toEqual([2,4,6,8,10,12]);
});
it('8) supports kn±c progression (3n+1)', () => {
expect(parseSelection('3n+1', 10)).toEqual([1,4,7,10]);
});
it('9) supports kn±c progression (4n-1)', () => {
expect(parseSelection('4n-1', 15)).toEqual([3,7,11,15]);
});
it('10) supports logical AND (&) intersection', () => {
// even AND 1-10 => even numbers within 1..10
expect(parseSelection('even & 1-10', 20)).toEqual([2,4,6,8,10]);
});
it('11) supports logical OR with comma', () => {
expect(parseSelection('1-3, 8-9', 20)).toEqual([1,2,3,8,9]);
});
it('12) supports logical OR with | and word or', () => {
expect(parseSelection('1-2 | 9-10 or 5', 20)).toEqual([1,2,5,9,10]);
});
it('13) supports NOT operator !', () => {
// !1-5 within max=10 -> 6..10
expect(parseSelection('!1-5', 10)).toEqual([6,7,8,9,10]);
});
it('14) supports combination: 1-10 & 2n & !5-7', () => {
expect(parseSelection('1-10 & 2n & !5-7', 20)).toEqual([2,4,8,10]);
});
it('15) preserves precedence: AND over OR', () => {
// 1-10 & even, 15 OR => ( (1-10 & even) , 15 )
expect(parseSelection('1-10 & even, 15', 20)).toEqual([2,4,6,8,10,15]);
});
it('16) handles whitespace and case-insensitive keywords', () => {
expect(parseSelection(' OdD & 1-7 ', 10)).toEqual([1,3,5,7]);
});
it('17) progression plus range: 2n | 9-11 within 12', () => {
expect(parseSelection('2n | 9-11', 12)).toEqual([2,4,6,8,9,10,11,12]);
});
it('18) complex: (2n-1 & 1-20) & ! (5-7)', () => {
expect(parseSelection('2n-1 & 1-20 & !5-7', 20)).toEqual([1,3,9,11,13,15,17,19]);
});
it('19) falls back to CSV when expression malformed', () => {
// malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty
expect(parseSelection('2x', 10)).toEqual([]);
// malformed middle; still fallback handles CSV bits
expect(parseSelection('1, 3-5, foo, 9', 10)).toEqual([1,3,4,5,9]);
});
it('20) clamps ranges that exceed bounds', () => {
expect(parseSelection('0-5, 9-10', 10)).toEqual([1,2,3,4,5,9,10]);
});
it('21) supports parentheses to override precedence', () => {
// Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15]
// With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10]
expect(parseSelection('1-10 & (even, 15)', 20)).toEqual([2,4,6,8,10]);
});
it('22) NOT over a grouped intersection', () => {
// !(10-20 & !2n) within 1..25
// Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded
// Complement in 1..25 removes those, keeping others
const result = parseSelection('!(10-20 & !2n)', 25);
expect(result).toEqual([1,2,3,4,5,6,7,8,9,10,12,14,16,18,20,21,22,23,24,25]);
});
it('23) nested parentheses with progressions', () => {
expect(parseSelection('(2n | 3n+1) & 1-20', 50)).toEqual([
1,2,4,6,7,8,10,12,13,14,16,18,19,20
]);
});
it('24) parentheses with NOT directly on group', () => {
expect(parseSelection('!(1-5, odd)', 10)).toEqual([6,8,10]);
});
it('25) whitespace within parentheses is ignored', () => {
expect(parseSelection('( 1 - 3 , 6 )', 10)).toEqual([1,2,3,6]);
});
it('26) malformed missing closing parenthesis falls back to CSV', () => {
// Expression parse should fail; fallback CSV should pick numbers only
expect(parseSelection('(1-3, 6', 10)).toEqual([6]);
});
it('27) nested NOT and AND with parentheses', () => {
// !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9
expect(parseSelection('!(odd & 5-9)', 12)).toEqual([1,2,3,4,6,8,10,11,12]);
});
it('28) deep nesting and mixing operators', () => {
const expr = '(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)';
expect(parseSelection(expr, 20)).toEqual([1,2,4,5,7,9,10,13,16,19]);
});
it('31) word NOT works like ! for terms', () => {
expect(parseSelection('not 1-3', 6)).toEqual([4,5,6]);
});
it('32) word NOT works like ! for groups', () => {
expect(parseSelection('not (odd & 1-6)', 8)).toEqual([2,4,6,7,8]);
});
it('29) parentheses around a single term has no effect', () => {
expect(parseSelection('(even)', 8)).toEqual([2,4,6,8]);
});
it('30) redundant nested parentheses', () => {
expect(parseSelection('(((1-3))), ((2n))', 6)).toEqual([1,2,3,4,6]);
});
// Additional edge cases and comprehensive coverage
it('33) handles empty input gracefully', () => {
expect(parseSelection('', 10)).toEqual([]);
expect(parseSelection(' ', 10)).toEqual([]);
});
it('34) handles zero or negative maxPages', () => {
expect(parseSelection('1-10', 0)).toEqual([]);
expect(parseSelection('1-10', -5)).toEqual([]);
});
it('35) handles large progressions efficiently', () => {
expect(parseSelection('100n', 1000)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]);
});
it('36) handles progressions with large offsets', () => {
expect(parseSelection('5n+97', 100)).toEqual([97]);
expect(parseSelection('3n-2', 10)).toEqual([1, 4, 7, 10]);
});
it('37) mixed case keywords work correctly', () => {
expect(parseSelection('EVEN & Odd', 6)).toEqual([]);
expect(parseSelection('Even OR odd', 6)).toEqual([1, 2, 3, 4, 5, 6]);
});
it('38) complex nested expressions with all operators', () => {
const expr = '(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)';
// (1-20 & even) = [2,4,6,8,10,12,14,16,18,20]
// (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19]
// (3n+1 & 1-10) = [1,4,7,10]
// Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20]
expect(parseSelection(expr, 20)).toEqual([1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20]);
});
it('39) multiple NOT operators in sequence', () => {
expect(parseSelection('not not 1-5', 10)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection('!!!1-3', 10)).toEqual([4, 5, 6, 7, 8, 9, 10]);
});
it('40) edge case: single page selection', () => {
expect(parseSelection('1', 1)).toEqual([1]);
expect(parseSelection('5', 3)).toEqual([]);
});
it('41) backwards ranges are handled correctly', () => {
expect(parseSelection('10-5', 15)).toEqual([5, 6, 7, 8, 9, 10]);
});
it('42) progressions that start beyond maxPages', () => {
expect(parseSelection('10n+50', 40)).toEqual([]);
expect(parseSelection('5n+35', 40)).toEqual([35, 40]);
});
it('43) complex operator precedence with mixed syntax', () => {
// AND has higher precedence than OR
expect(parseSelection('1-3, 5-7 & even', 10)).toEqual([1, 2, 3, 6]);
expect(parseSelection('1-3 | 5-7 and even', 10)).toEqual([1, 2, 3, 6]);
});
it('44) whitespace tolerance in complex expressions', () => {
const expr1 = '1-5&even|odd&!3';
const expr2 = ' 1 - 5 & even | odd & ! 3 ';
expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10));
});
it('45) fallback behavior with partial valid expressions', () => {
// Should fallback and extract valid CSV parts
expect(parseSelection('1, 2-4, invalid, 7', 10)).toEqual([1, 2, 3, 4, 7]);
expect(parseSelection('1-3, @#$, 8-9', 10)).toEqual([1, 2, 3, 8, 9]);
});
it('46) progressions with k=1 (equivalent to n)', () => {
expect(parseSelection('1n', 5)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection('1n+2', 5)).toEqual([2, 3, 4, 5]);
});
it('47) very large ranges are clamped correctly', () => {
expect(parseSelection('1-999999', 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work
expect(parseSelection('0-5', 10)).toEqual([1, 2, 3, 4, 5]);
});
it('48) multiple comma-separated ranges', () => {
expect(parseSelection('1-2, 4-5, 7-8, 10', 10)).toEqual([1, 2, 4, 5, 7, 8, 10]);
});
it('49) combination of all features in one expression', () => {
const expr = '(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n';
const result = parseSelection(expr, 100);
// This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n
expect(result.length).toBeGreaterThan(0);
expect(result).toContain(50);
expect(result).toContain(100);
});
it('50) stress test with deeply nested parentheses', () => {
const expr = '((((1-5)))) & ((((even)))) | ((((odd & 7-9))))';
expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]);
});
});

View File

@@ -0,0 +1,413 @@
// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1")
// into a list of page numbers within [1, maxPages].
/*
Supported grammar (case-insensitive for words):
expression := disjunction
disjunction := conjunction ( ("," | "|" | "or") conjunction )*
conjunction := unary ( ("&" | "and") unary )*
unary := ("!" unary) | ("not" unary) | primary
primary := "(" expression ")" | range | progression | keyword | number
range := number "-" number // inclusive
progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0
keyword := "even" | "odd"
number := digits (>= 1)
Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR)
Associativity: left-to-right within the same precedence level
Notes:
- Whitespace is ignored.
- The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe.
- Out-of-bounds numbers are clamped in ranges and ignored as singletons.
- On parse failure, the parser falls back to CSV (numbers and ranges separated by commas).
Examples:
1-10 & even -> even pages between 1 and 10
!(5-7) -> all pages except 5..7
3n+1 -> 1,4,7,... (n starts at 0)
(2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20
*/
export function parseSelection(input: string, maxPages: number): number[] {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return [];
const trimmed = (input || '').trim();
if (trimmed.length === 0) return [];
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return toSortedArray(resultSet);
} catch {
// Fallback: simple CSV parser (e.g., "1,3,5-10")
return toSortedArray(parseCsvFallback(trimmed, clampedMax));
}
}
export function parseSelectionWithDiagnostics(
input: string,
maxPages: number,
options?: { strict?: boolean }
): { pages: number[]; warning?: string } {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return { pages: [] };
const trimmed = (input || '').trim();
if (trimmed.length === 0) return { pages: [] };
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return { pages: toSortedArray(resultSet) };
} catch (err) {
if (options?.strict) {
throw err;
}
const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax));
const tokens = trimmed.split(',').map(t => t.trim()).filter(Boolean);
const bad = tokens.find(tok => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok));
const warning = `Malformed expression${bad ? ` at: '${bad}'` : ''}. Falling back to CSV interpretation.`;
return { pages, warning };
}
}
function toSortedArray(set: Set<number>): number[] {
return Array.from(set).sort((a, b) => a - b);
}
function parseCsvFallback(input: string, max: number): Set<number> {
const result = new Set<number>();
const parts = input.split(',').map(p => p.trim()).filter(Boolean);
for (const part of parts) {
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const start = clampToRange(parseInt(rangeMatch[1], 10), 1, max);
const end = clampToRange(parseInt(rangeMatch[2], 10), 1, max);
if (Number.isFinite(start) && Number.isFinite(end)) {
const [lo, hi] = start <= end ? [start, end] : [end, start];
for (let i = lo; i <= hi; i++) result.add(i);
}
continue;
}
// Accept only pure positive integers (no signs, no letters)
if (/^\d+$/.test(part)) {
const n = parseInt(part, 10);
if (Number.isFinite(n) && n >= 1 && n <= max) result.add(n);
}
}
return result;
}
function clampToRange(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return NaN as unknown as number;
return Math.min(Math.max(v, min), max);
}
class ExpressionParser {
private readonly src: string;
private readonly max: number;
private idx: number = 0;
constructor(source: string, maxPages: number) {
this.src = source;
this.max = maxPages;
}
parse(): Set<number> {
this.skipWs();
const set = this.parseDisjunction();
this.skipWs();
// If there are leftover non-space characters, treat as error
if (this.idx < this.src.length) {
throw new Error('Unexpected trailing input');
}
return set;
}
private parseDisjunction(): Set<number> {
let left = this.parseConjunction();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === 'symbol' && (op.value === ',' || op.value === '|')) {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
if (op.type === 'word' && op.value === 'or') {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
break;
}
return left;
}
private parseConjunction(): Set<number> {
let left = this.parseUnary();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === 'symbol' && op.value === '&') {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
if (op.type === 'word' && op.value === 'and') {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
break;
}
return left;
}
private parseUnary(): Set<number> {
this.skipWs();
if (this.peek('!')) {
this.consume(1);
const inner = this.parseUnary();
return complement(inner, this.max);
}
// Word-form NOT
if (this.tryConsumeNot()) {
const inner = this.parseUnary();
return complement(inner, this.max);
}
return this.parsePrimary();
}
private parsePrimary(): Set<number> {
this.skipWs();
// Parenthesized expression: '(' expression ')'
if (this.peek('(')) {
this.consume(1);
const inner = this.parseDisjunction();
this.skipWs();
if (!this.peek(')')) throw new Error('Expected )');
this.consume(1);
return inner;
}
// Keywords: even / odd
const keyword = this.tryReadKeyword();
if (keyword) {
if (keyword === 'even') return this.buildEven();
if (keyword === 'odd') return this.buildOdd();
}
// Progression: k n ( +/- c )?
const progression = this.tryReadProgression();
if (progression) {
return this.buildProgression(progression.k, progression.c);
}
// Number or Range
const num = this.tryReadNumber();
if (num !== null) {
this.skipWs();
if (this.peek('-')) {
// Range
this.consume(1);
this.skipWs();
const end = this.readRequiredNumber();
return this.buildRange(num, end);
}
return this.buildSingleton(num);
}
// If nothing matched, error
throw new Error('Expected primary');
}
private buildSingleton(n: number): Set<number> {
const set = new Set<number>();
if (n >= 1 && n <= this.max) set.add(n);
return set;
}
private buildRange(a: number, b: number): Set<number> {
const set = new Set<number>();
let start = a, end = b;
if (!Number.isFinite(start) || !Number.isFinite(end)) return set;
if (start > end) [start, end] = [end, start];
start = Math.max(1, start);
end = Math.min(this.max, end);
for (let i = start; i <= end; i++) set.add(i);
return set;
}
private buildProgression(k: number, c: number): Set<number> {
const set = new Set<number>();
if (!(k >= 1)) return set;
// n starts at 0: k*n + c, for n=0,1,2,... while within [1..max]
for (let n = 0; ; n++) {
const value = k * n + c;
if (value > this.max) break;
if (value >= 1) set.add(value);
}
return set;
}
private buildEven(): Set<number> {
return this.buildProgression(2, 0);
}
private buildOdd(): Set<number> {
return this.buildProgression(2, -1);
}
private tryReadKeyword(): 'even' | 'odd' | null {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
if (lower === 'even' || lower === 'odd') {
return lower as 'even' | 'odd';
}
// Not a keyword; rewind
this.idx = start;
return null;
}
private tryReadProgression(): { k: number; c: number } | null {
const start = this.idx;
this.skipWs();
const k = this.tryReadNumber();
if (k === null) {
this.idx = start;
return null;
}
this.skipWs();
// Optional '*'
if (this.peek('*')) this.consume(1);
this.skipWs();
if (!this.peek('n') && !this.peek('N')) {
this.idx = start;
return null;
}
this.consume(1); // consume 'n'
this.skipWs();
// Optional (+|-) c
let c = 0;
if (this.peek('+') || this.peek('-')) {
const sign = this.src[this.idx];
this.consume(1);
this.skipWs();
const cVal = this.tryReadNumber();
if (cVal === null) {
this.idx = start;
return null;
}
c = sign === '-' ? -cVal : cVal;
}
return { k, c };
}
private tryReadNumber(): number | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^(\d+)/);
if (!m) return null;
this.consume(m[1].length);
const num = parseInt(m[1], 10);
return Number.isFinite(num) ? num : null;
}
private readRequiredNumber(): number {
const n = this.tryReadNumber();
if (n === null) throw new Error('Expected number');
return n;
}
private readWord(): string | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^([A-Za-z]+)/);
if (!m) return null;
this.consume(m[1].length);
return m[1];
}
private tryConsumeNot(): boolean {
const start = this.idx;
const word = this.readWord();
if (!word) {
this.idx = start;
return false;
}
if (word.toLowerCase() === 'not') {
return true;
}
this.idx = start;
return false;
}
private peekWordOrSymbol(): { type: 'word' | 'symbol'; value: string; raw: string; length: number } | null {
this.skipWs();
if (this.idx >= this.src.length) return null;
const ch = this.src[this.idx];
if (/[A-Za-z]/.test(ch)) {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
// Always rewind; the caller will consume if it uses this token
const len = word.length;
this.idx = start;
if (lower === 'and' || lower === 'or') {
return { type: 'word', value: lower, raw: word, length: len };
}
return null;
}
if (ch === '&' || ch === '|' || ch === ',') {
return { type: 'symbol', value: ch, raw: ch, length: 1 };
}
return null;
}
private skipWs() {
while (this.idx < this.src.length && /\s/.test(this.src[this.idx])) this.idx++;
}
private peek(s: string): boolean {
return this.src.startsWith(s, this.idx);
}
private consume(n: number) {
this.idx += n;
}
}
function union(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0) return new Set(b);
if (b.size === 0) return new Set(a);
const out = new Set<number>(a);
for (const v of b) out.add(v);
return out;
}
function intersect(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0 || b.size === 0) return new Set<number>();
const out = new Set<number>();
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
for (const v of small) if (large.has(v)) out.add(v);
return out;
}
function complement(a: Set<number>, max: number): Set<number> {
const out = new Set<number>();
for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i);
return out;
}