返回 slidev
009-parserangestring-bounds.md
根目录 / plans / 009-parserangestring-bounds.md
1 # Plan 009: Add lower-bound / NaN validation to `parseRangeString`
2
3 > **Executor instructions**: Follow this plan step by step. Run every
4 > verification command and confirm the expected result. If anything in "STOP
5 > conditions" occurs, stop and report. When done, update the status row in
6 > `plans/README.md`.
7 >
8 > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/parser/src/utils.ts`
9 > On a mismatch with the excerpt below, treat it as a STOP condition.
10
11 ## Status
12
13 - **Priority**: P2
14 - **Effort**: S
15 - **Risk**: LOW
16 - **Depends on**: none
17 - **Category**: bug
18 - **Planned at**: commit `c63cb120`, 2026-07-10
19
20 ## Why this matters
21
22 `parseRangeString` filters only the **upper** bound (`i <= total`). A malformed
23 range fragment can yield index `0` or negative indices: `"0"` passes, and
24 `"-3"` splits to `['', '3']` → `range(0, 4)` → `[0,1,2,3]`. Index `0` then
25 reaches `md.slides[index - 1]` → `slides[-1]` (`undefined`, caught as a load
26 error) and export page ranges (`commands/export.ts:189`), producing timeouts or
27 confusing "slide failed to load" errors instead of a clean ignore. Clamping to
28 `1..total` and dropping `NaN` makes range parsing total.
29
30 ## Current state
31
32 `packages/parser/src/utils.ts:10-31`:
33 ```ts
34 export function parseRangeString(total: number, rangeStr?: string) {
35 if (!rangeStr || rangeStr === 'all' || rangeStr === '*')
36 return range(1, total + 1)
37 if (rangeStr === 'none')
38 return []
39
40 const indexes: number[] = []
41 for (const part of rangeStr.split(/[,;]/g)) {
42 if (!part.includes('-')) {
43 indexes.push(+part)
44 }
45 else {
46 const [start, end] = part.split('-', 2)
47 indexes.push(
48 ...range(+start, !end ? (total + 1) : (+end + 1)),
49 )
50 }
51 }
52
53 return uniq(indexes).filter(i => i <= total).sort((a, b) => a - b)
54 }
55 ```
56 `range` is `@antfu/utils`' `range`. Consumers: `parser/src/fs.ts:81` (slide
57 `src` ranges) and `commands/export.ts:189` (export page selection).
58
59 There is no colocated test for `utils.ts` today; the sibling
60 `packages/parser/src/timesplit/timesplit.test.ts` shows the colocated Vitest +
61 `toMatchInlineSnapshot` pattern.
62
63 ## Commands you will need
64
65 | Purpose | Command | Expected |
66 |---------|---------|----------|
67 | Install | `pnpm install` | exit 0 |
68 | Build | `pnpm build` | exit 0 |
69 | Test (parser) | `pnpm test -- utils` | new test passes |
70 | Typecheck | `pnpm typecheck` | exit 0 |
71
72 ## Scope
73
74 **In scope**:
75 - `packages/parser/src/utils.ts` (tighten the final filter)
76 - `packages/parser/src/utils.test.ts` (create)
77
78 **Out of scope**:
79 - `parseAspectRatio` in the same file (leave it).
80 - Circular-import handling (plan 008).
81
82 ## Git workflow
83
84 - Branch: `fix/parse-range-bounds`.
85 - Conventional commit: `fix(parser): clamp parseRangeString to valid slide indices`.
86 - Do NOT push/PR unless instructed.
87
88 ## Steps
89
90 ### Step 1: Tighten the final filter
91
92 Replace the return line so it drops `NaN` and enforces a lower bound of 1:
93 ```ts
94 return uniq(indexes)
95 .filter(i => Number.isInteger(i) && i >= 1 && i <= total)
96 .sort((a, b) => a - b)
97 ```
98
99 **Verify**: reading the function, `"0"`, `"-3"`, and `"abc"` can no longer
100 contribute an index `< 1` or `NaN`.
101
102 ### Step 2: Add a colocated test
103
104 Create `packages/parser/src/utils.test.ts`, modeled on
105 `packages/parser/src/timesplit/timesplit.test.ts`:
106 ```ts
107 import { describe, expect, it } from 'vitest'
108 import { parseRangeString } from './utils'
109
110 describe('parseRangeString', () => {
111 it('returns all when empty/all/*', () => {
112 expect(parseRangeString(3)).toEqual([1, 2, 3])
113 expect(parseRangeString(3, 'all')).toEqual([1, 2, 3])
114 expect(parseRangeString(3, '*')).toEqual([1, 2, 3])
115 })
116 it('returns none for "none"', () => {
117 expect(parseRangeString(3, 'none')).toEqual([])
118 })
119 it('parses lists and ranges', () => {
120 expect(parseRangeString(8, '1,3-5,8')).toEqual([1, 3, 4, 5, 8])
121 })
122 it('clamps out-of-range and drops invalid parts', () => {
123 expect(parseRangeString(5, '0')).toEqual([])
124 expect(parseRangeString(5, '-3')).toEqual([]) // "-3" → ['', '3'] must not leak 0
125 expect(parseRangeString(5, 'abc')).toEqual([])
126 expect(parseRangeString(5, '3-99')).toEqual([3, 4, 5])
127 })
128 })
129 ```
130 Confirm the expected outputs against your Step 1 implementation before finalizing
131 (especially the `'-3'` case — assert it produces `[]`, i.e. no `0`, given the
132 current `split('-')` behavior).
133
134 **Verify**: `pnpm build && pnpm test -- utils` → all cases pass.
135
136 ## Test plan
137
138 - New test file `packages/parser/src/utils.test.ts` covering: default/all/none,
139 list+range parsing (the documented `1,3-5,8` example), and the bug cases
140 (`0`, `-3`, `abc`, over-`total`). This is the regression guard.
141
142 ## Done criteria
143
144 - [ ] `parseRangeString` never returns an index `< 1`, `> total`, or `NaN`
145 - [ ] `packages/parser/src/utils.test.ts` exists and passes
146 - [ ] The documented example `1,3-5,8` → `[1,3,4,5,8]` still holds
147 - [ ] `pnpm build && pnpm typecheck` exit 0
148 - [ ] Only in-scope files modified (`git status`)
149 - [ ] `plans/README.md` status row updated
150
151 ## STOP conditions
152
153 Stop and report if:
154
155 - Any existing consumer relies on `0`/negative being returned (search
156 `parseRangeString` usages; none should, but confirm).
157
158 ## Maintenance notes
159
160 - If negative "from-end" indexing is ever desired, do it explicitly, not via the
161 current `split('-')` accident.
162 - Reviewer: confirm export page ranges and `src` ranges behave identically for
163 valid inputs (no behavior change on the happy path).
164
164 lines MARKDOWN