detached a171ee0

feat(docmodel): add heading motion foundation

commit
a171ee095b8dcc602489ceb011c56303d5ea6623
author
brice · · 2026-08-14 04:23 UTC
parent
21d72f7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +573 -0
A internal/domain/docmodel/docmodel.go +133 -0
@@ -0,0 +1,133 @@
1+package docmodel
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+// BlockID identifies a block within a document.
9+type BlockID string
10+
11+// BlockKind describes the semantic role of a block. Non-heading kinds are
12+// intentionally left schema-agnostic and are preserved as provided.
13+type BlockKind string
14+
15+const (
16+ // BlockKindHeading marks blocks that participate in heading motions.
17+ BlockKindHeading BlockKind = "heading"
18+
19+ // MinHeadingLevel is the lowest valid heading level.
20+ MinHeadingLevel = 1
21+ // MaxHeadingLevel is the highest valid heading level.
22+ MaxHeadingLevel = 6
23+)
24+
25+var (
26+ // ErrEmptyBlockID reports a block with no ID.
27+ ErrEmptyBlockID = errors.New("docmodel: empty block ID")
28+ // ErrDuplicateBlockID reports a repeated block ID.
29+ ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID")
30+ // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6.
31+ ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level")
32+ // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level.
33+ ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level")
34+)
35+
36+// Block is an ordered document block. HeadingLevel is meaningful only when
37+// Kind is BlockKindHeading; non-heading blocks must leave it as zero.
38+type Block struct {
39+ ID BlockID
40+ Kind BlockKind
41+ HeadingLevel int
42+}
43+
44+// IsHeading reports whether the block is a heading.
45+func (b Block) IsHeading() bool {
46+ return b.Kind == BlockKindHeading
47+}
48+
49+// Cursor identifies the current block for document navigation.
50+type Cursor struct {
51+ BlockID BlockID
52+}
53+
54+// Document is an immutable, order-preserving collection of blocks.
55+type Document struct {
56+ blocks []Block
57+ indexByID map[BlockID]int
58+}
59+
60+// NewDocument validates blocks and returns an order-preserving document. The
61+// input slice is copied so later caller mutations cannot affect navigation.
62+func NewDocument(blocks []Block) (Document, error) {
63+ copied := append([]Block(nil), blocks...)
64+ indexByID := make(map[BlockID]int, len(copied))
65+
66+ for i, block := range copied {
67+ if block.ID == "" {
68+ return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i)
69+ }
70+ if _, exists := indexByID[block.ID]; exists {
71+ return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i)
72+ }
73+ if err := validateBlockHeading(block, i); err != nil {
74+ return Document{}, err
75+ }
76+
77+ indexByID[block.ID] = i
78+ }
79+
80+ return Document{
81+ blocks: copied,
82+ indexByID: indexByID,
83+ }, nil
84+}
85+
86+// Len returns the number of blocks in document order.
87+func (d Document) Len() int {
88+ return len(d.blocks)
89+}
90+
91+// Blocks returns a copy of the blocks in document order.
92+func (d Document) Blocks() []Block {
93+ return append([]Block(nil), d.blocks...)
94+}
95+
96+// BlockByID returns the block with id when present.
97+func (d Document) BlockByID(id BlockID) (Block, bool) {
98+ idx, ok := d.indexByID[id]
99+ if !ok {
100+ return Block{}, false
101+ }
102+ return d.blocks[idx], true
103+}
104+
105+// CursorByID returns a cursor positioned on id when present.
106+func (d Document) CursorByID(id BlockID) (Cursor, bool) {
107+ if _, ok := d.indexByID[id]; !ok {
108+ return Cursor{}, false
109+ }
110+ return Cursor{BlockID: id}, true
111+}
112+
113+// FirstCursor returns a cursor positioned on the first block in document order.
114+func (d Document) FirstCursor() (Cursor, bool) {
115+ if len(d.blocks) == 0 {
116+ return Cursor{}, false
117+ }
118+ return Cursor{BlockID: d.blocks[0].ID}, true
119+}
120+
121+func validateBlockHeading(block Block, index int) error {
122+ if block.IsHeading() {
123+ if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel {
124+ return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index)
125+ }
126+ return nil
127+ }
128+
129+ if block.HeadingLevel != 0 {
130+ return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index)
131+ }
132+ return nil
133+}
@@ -0,0 +1,133 @@
1+package docmodel
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+// BlockID identifies a block within a document.
9+type BlockID string
10+
11+// BlockKind describes the semantic role of a block. Non-heading kinds are
12+// intentionally left schema-agnostic and are preserved as provided.
13+type BlockKind string
14+
15+const (
16+ // BlockKindHeading marks blocks that participate in heading motions.
17+ BlockKindHeading BlockKind = "heading"
18+
19+ // MinHeadingLevel is the lowest valid heading level.
20+ MinHeadingLevel = 1
21+ // MaxHeadingLevel is the highest valid heading level.
22+ MaxHeadingLevel = 6
23+)
24+
25+var (
26+ // ErrEmptyBlockID reports a block with no ID.
27+ ErrEmptyBlockID = errors.New("docmodel: empty block ID")
28+ // ErrDuplicateBlockID reports a repeated block ID.
29+ ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID")
30+ // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6.
31+ ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level")
32+ // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level.
33+ ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level")
34+)
35+
36+// Block is an ordered document block. HeadingLevel is meaningful only when
37+// Kind is BlockKindHeading; non-heading blocks must leave it as zero.
38+type Block struct {
39+ ID BlockID
40+ Kind BlockKind
41+ HeadingLevel int
42+}
43+
44+// IsHeading reports whether the block is a heading.
45+func (b Block) IsHeading() bool {
46+ return b.Kind == BlockKindHeading
47+}
48+
49+// Cursor identifies the current block for document navigation.
50+type Cursor struct {
51+ BlockID BlockID
52+}
53+
54+// Document is an immutable, order-preserving collection of blocks.
55+type Document struct {
56+ blocks []Block
57+ indexByID map[BlockID]int
58+}
59+
60+// NewDocument validates blocks and returns an order-preserving document. The
61+// input slice is copied so later caller mutations cannot affect navigation.
62+func NewDocument(blocks []Block) (Document, error) {
63+ copied := append([]Block(nil), blocks...)
64+ indexByID := make(map[BlockID]int, len(copied))
65+
66+ for i, block := range copied {
67+ if block.ID == "" {
68+ return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i)
69+ }
70+ if _, exists := indexByID[block.ID]; exists {
71+ return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i)
72+ }
73+ if err := validateBlockHeading(block, i); err != nil {
74+ return Document{}, err
75+ }
76+
77+ indexByID[block.ID] = i
78+ }
79+
80+ return Document{
81+ blocks: copied,
82+ indexByID: indexByID,
83+ }, nil
84+}
85+
86+// Len returns the number of blocks in document order.
87+func (d Document) Len() int {
88+ return len(d.blocks)
89+}
90+
91+// Blocks returns a copy of the blocks in document order.
92+func (d Document) Blocks() []Block {
93+ return append([]Block(nil), d.blocks...)
94+}
95+
96+// BlockByID returns the block with id when present.
97+func (d Document) BlockByID(id BlockID) (Block, bool) {
98+ idx, ok := d.indexByID[id]
99+ if !ok {
100+ return Block{}, false
101+ }
102+ return d.blocks[idx], true
103+}
104+
105+// CursorByID returns a cursor positioned on id when present.
106+func (d Document) CursorByID(id BlockID) (Cursor, bool) {
107+ if _, ok := d.indexByID[id]; !ok {
108+ return Cursor{}, false
109+ }
110+ return Cursor{BlockID: id}, true
111+}
112+
113+// FirstCursor returns a cursor positioned on the first block in document order.
114+func (d Document) FirstCursor() (Cursor, bool) {
115+ if len(d.blocks) == 0 {
116+ return Cursor{}, false
117+ }
118+ return Cursor{BlockID: d.blocks[0].ID}, true
119+}
120+
121+func validateBlockHeading(block Block, index int) error {
122+ if block.IsHeading() {
123+ if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel {
124+ return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index)
125+ }
126+ return nil
127+ }
128+
129+ if block.HeadingLevel != 0 {
130+ return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index)
131+ }
132+ return nil
133+}
A internal/domain/docmodel/docmodel_test.go +384 -0
@@ -0,0 +1,384 @@
1+package docmodel
2+
3+import (
4+ "errors"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestNewDocumentValidation(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ blocks []Block
13+ wantErr error
14+ }{
15+ {
16+ name: "empty document is valid",
17+ },
18+ {
19+ name: "valid headings and non headings",
20+ blocks: []Block{
21+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
22+ {ID: "body", Kind: BlockKind("paragraph")},
23+ {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6},
24+ },
25+ },
26+ {
27+ name: "empty block ID is invalid",
28+ blocks: []Block{
29+ {ID: "ok", Kind: BlockKind("paragraph")},
30+ {ID: "", Kind: BlockKindHeading, HeadingLevel: 1},
31+ },
32+ wantErr: ErrEmptyBlockID,
33+ },
34+ {
35+ name: "duplicate block ID is invalid",
36+ blocks: []Block{
37+ {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1},
38+ {ID: "same", Kind: BlockKind("paragraph")},
39+ },
40+ wantErr: ErrDuplicateBlockID,
41+ },
42+ {
43+ name: "heading level below one is invalid",
44+ blocks: []Block{
45+ {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0},
46+ },
47+ wantErr: ErrInvalidHeadingLevel,
48+ },
49+ {
50+ name: "heading level above six is invalid",
51+ blocks: []Block{
52+ {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7},
53+ },
54+ wantErr: ErrInvalidHeadingLevel,
55+ },
56+ {
57+ name: "non heading cannot carry heading level",
58+ blocks: []Block{
59+ {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2},
60+ },
61+ wantErr: ErrUnexpectedHeadingLevel,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ _, err := NewDocument(tt.blocks)
68+ if tt.wantErr == nil {
69+ if err != nil {
70+ t.Fatalf("NewDocument() error = %v, want nil", err)
71+ }
72+ return
73+ }
74+
75+ if !errors.Is(err, tt.wantErr) {
76+ t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr)
77+ }
78+ })
79+ }
80+}
81+
82+func TestDocumentOrderAndCopies(t *testing.T) {
83+ blocks := []Block{
84+ {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
85+ {ID: "body", Kind: BlockKind("paragraph")},
86+ {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
87+ }
88+
89+ doc, err := NewDocument(blocks)
90+ if err != nil {
91+ t.Fatalf("NewDocument() error = %v", err)
92+ }
93+
94+ blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6}
95+
96+ got := doc.Blocks()
97+ want := []Block{
98+ {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
99+ {ID: "body", Kind: BlockKind("paragraph")},
100+ {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
101+ }
102+ if !reflect.DeepEqual(got, want) {
103+ t.Fatalf("Blocks() = %#v, want %#v", got, want)
104+ }
105+
106+ got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")}
107+ gotAgain := doc.Blocks()
108+ if !reflect.DeepEqual(gotAgain, want) {
109+ t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want)
110+ }
111+}
112+
113+func TestDocumentCursorNavigation(t *testing.T) {
114+ t.Run("empty document has no first cursor", func(t *testing.T) {
115+ doc, err := NewDocument(nil)
116+ if err != nil {
117+ t.Fatalf("NewDocument() error = %v", err)
118+ }
119+
120+ if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) {
121+ t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok)
122+ }
123+ })
124+
125+ t.Run("cursor by ID and first cursor use document order", func(t *testing.T) {
126+ doc := mustDocument(t, []Block{
127+ {ID: "first", Kind: BlockKind("paragraph")},
128+ {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2},
129+ })
130+
131+ if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) {
132+ t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok)
133+ }
134+
135+ if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) {
136+ t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok)
137+ }
138+
139+ if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) {
140+ t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok)
141+ }
142+ })
143+}
144+
145+func TestStrictNextHeading(t *testing.T) {
146+ doc := headingMotionDocument(t)
147+
148+ tests := []struct {
149+ name string
150+ from BlockID
151+ count int
152+ levels []int
153+ want Cursor
154+ ok bool
155+ }{
156+ {
157+ name: "from non heading uses next heading",
158+ from: "p0",
159+ want: Cursor{BlockID: "h1"},
160+ ok: true,
161+ },
162+ {
163+ name: "count below one means one",
164+ from: "p1",
165+ count: -3,
166+ want: Cursor{BlockID: "h2"},
167+ ok: true,
168+ },
169+ {
170+ name: "strict next does not match current heading",
171+ from: "h1",
172+ count: 1,
173+ want: Cursor{BlockID: "h2"},
174+ ok: true,
175+ },
176+ {
177+ name: "count selects nth next heading",
178+ from: "h1",
179+ count: 2,
180+ want: Cursor{BlockID: "h3"},
181+ ok: true,
182+ },
183+ {
184+ name: "level filter skips other headings",
185+ from: "h1",
186+ count: 1,
187+ levels: []int{1},
188+ want: Cursor{BlockID: "h1b"},
189+ ok: true,
190+ },
191+ {
192+ name: "count applies after filtering",
193+ from: "h1",
194+ count: 2,
195+ levels: []int{2},
196+ want: Cursor{BlockID: "h2b"},
197+ ok: true,
198+ },
199+ {
200+ name: "multi level filter matches any listed level",
201+ from: "h2",
202+ count: 1,
203+ levels: []int{1, 3},
204+ want: Cursor{BlockID: "h3"},
205+ ok: true,
206+ },
207+ {
208+ name: "no matching level returns false",
209+ from: "p0",
210+ levels: []int{6},
211+ },
212+ {
213+ name: "past last heading returns false",
214+ from: "h1b",
215+ },
216+ {
217+ name: "missing cursor returns false",
218+ from: "missing",
219+ },
220+ }
221+
222+ for _, tt := range tests {
223+ t.Run(tt.name, func(t *testing.T) {
224+ before := doc.Blocks()
225+ got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
226+ if ok != tt.ok || got != tt.want {
227+ t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
228+ }
229+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
230+ t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after)
231+ }
232+ })
233+ }
234+}
235+
236+func TestStrictPreviousHeading(t *testing.T) {
237+ doc := headingMotionDocument(t)
238+
239+ tests := []struct {
240+ name string
241+ from BlockID
242+ count int
243+ levels []int
244+ want Cursor
245+ ok bool
246+ }{
247+ {
248+ name: "from non heading uses previous heading",
249+ from: "p2",
250+ want: Cursor{BlockID: "h2b"},
251+ ok: true,
252+ },
253+ {
254+ name: "count below one means one",
255+ from: "p2",
256+ count: 0,
257+ want: Cursor{BlockID: "h2b"},
258+ ok: true,
259+ },
260+ {
261+ name: "strict previous does not match current heading",
262+ from: "h1b",
263+ count: 1,
264+ want: Cursor{BlockID: "h2b"},
265+ ok: true,
266+ },
267+ {
268+ name: "count selects nth previous heading",
269+ from: "h1b",
270+ count: 2,
271+ want: Cursor{BlockID: "h3"},
272+ ok: true,
273+ },
274+ {
275+ name: "level filter skips other headings",
276+ from: "h1b",
277+ count: 1,
278+ levels: []int{1},
279+ want: Cursor{BlockID: "h1"},
280+ ok: true,
281+ },
282+ {
283+ name: "count applies after filtering",
284+ from: "h1b",
285+ count: 2,
286+ levels: []int{2},
287+ want: Cursor{BlockID: "h2"},
288+ ok: true,
289+ },
290+ {
291+ name: "multi level filter matches any listed level",
292+ from: "h2b",
293+ count: 1,
294+ levels: []int{1, 3},
295+ want: Cursor{BlockID: "h3"},
296+ ok: true,
297+ },
298+ {
299+ name: "no matching level returns false",
300+ from: "h1b",
301+ levels: []int{6},
302+ },
303+ {
304+ name: "before first heading returns false",
305+ from: "h1",
306+ },
307+ {
308+ name: "missing cursor returns false",
309+ from: "missing",
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ before := doc.Blocks()
316+ got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
317+ if ok != tt.ok || got != tt.want {
318+ t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
319+ }
320+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
321+ t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after)
322+ }
323+ })
324+ }
325+}
326+
327+func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) {
328+ tests := []struct {
329+ name string
330+ blocks []Block
331+ from Cursor
332+ }{
333+ {
334+ name: "empty document",
335+ from: Cursor{BlockID: "missing"},
336+ },
337+ {
338+ name: "document without headings",
339+ blocks: []Block{
340+ {ID: "p0", Kind: BlockKind("paragraph")},
341+ {ID: "p1", Kind: BlockKind("list_item")},
342+ },
343+ from: Cursor{BlockID: "p0"},
344+ },
345+ }
346+
347+ for _, tt := range tests {
348+ t.Run(tt.name, func(t *testing.T) {
349+ doc := mustDocument(t, tt.blocks)
350+
351+ if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) {
352+ t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok)
353+ }
354+ if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) {
355+ t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok)
356+ }
357+ })
358+ }
359+}
360+
361+func headingMotionDocument(t *testing.T) Document {
362+ t.Helper()
363+
364+ return mustDocument(t, []Block{
365+ {ID: "p0", Kind: BlockKind("paragraph")},
366+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
367+ {ID: "p1", Kind: BlockKind("paragraph")},
368+ {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
369+ {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
370+ {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
371+ {ID: "p2", Kind: BlockKind("paragraph")},
372+ {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
373+ })
374+}
375+
376+func mustDocument(t *testing.T, blocks []Block) Document {
377+ t.Helper()
378+
379+ doc, err := NewDocument(blocks)
380+ if err != nil {
381+ t.Fatalf("NewDocument() error = %v", err)
382+ }
383+ return doc
384+}
@@ -0,0 +1,384 @@
1+package docmodel
2+
3+import (
4+ "errors"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestNewDocumentValidation(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ blocks []Block
13+ wantErr error
14+ }{
15+ {
16+ name: "empty document is valid",
17+ },
18+ {
19+ name: "valid headings and non headings",
20+ blocks: []Block{
21+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
22+ {ID: "body", Kind: BlockKind("paragraph")},
23+ {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6},
24+ },
25+ },
26+ {
27+ name: "empty block ID is invalid",
28+ blocks: []Block{
29+ {ID: "ok", Kind: BlockKind("paragraph")},
30+ {ID: "", Kind: BlockKindHeading, HeadingLevel: 1},
31+ },
32+ wantErr: ErrEmptyBlockID,
33+ },
34+ {
35+ name: "duplicate block ID is invalid",
36+ blocks: []Block{
37+ {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1},
38+ {ID: "same", Kind: BlockKind("paragraph")},
39+ },
40+ wantErr: ErrDuplicateBlockID,
41+ },
42+ {
43+ name: "heading level below one is invalid",
44+ blocks: []Block{
45+ {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0},
46+ },
47+ wantErr: ErrInvalidHeadingLevel,
48+ },
49+ {
50+ name: "heading level above six is invalid",
51+ blocks: []Block{
52+ {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7},
53+ },
54+ wantErr: ErrInvalidHeadingLevel,
55+ },
56+ {
57+ name: "non heading cannot carry heading level",
58+ blocks: []Block{
59+ {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2},
60+ },
61+ wantErr: ErrUnexpectedHeadingLevel,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ _, err := NewDocument(tt.blocks)
68+ if tt.wantErr == nil {
69+ if err != nil {
70+ t.Fatalf("NewDocument() error = %v, want nil", err)
71+ }
72+ return
73+ }
74+
75+ if !errors.Is(err, tt.wantErr) {
76+ t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr)
77+ }
78+ })
79+ }
80+}
81+
82+func TestDocumentOrderAndCopies(t *testing.T) {
83+ blocks := []Block{
84+ {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
85+ {ID: "body", Kind: BlockKind("paragraph")},
86+ {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
87+ }
88+
89+ doc, err := NewDocument(blocks)
90+ if err != nil {
91+ t.Fatalf("NewDocument() error = %v", err)
92+ }
93+
94+ blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6}
95+
96+ got := doc.Blocks()
97+ want := []Block{
98+ {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
99+ {ID: "body", Kind: BlockKind("paragraph")},
100+ {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
101+ }
102+ if !reflect.DeepEqual(got, want) {
103+ t.Fatalf("Blocks() = %#v, want %#v", got, want)
104+ }
105+
106+ got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")}
107+ gotAgain := doc.Blocks()
108+ if !reflect.DeepEqual(gotAgain, want) {
109+ t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want)
110+ }
111+}
112+
113+func TestDocumentCursorNavigation(t *testing.T) {
114+ t.Run("empty document has no first cursor", func(t *testing.T) {
115+ doc, err := NewDocument(nil)
116+ if err != nil {
117+ t.Fatalf("NewDocument() error = %v", err)
118+ }
119+
120+ if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) {
121+ t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok)
122+ }
123+ })
124+
125+ t.Run("cursor by ID and first cursor use document order", func(t *testing.T) {
126+ doc := mustDocument(t, []Block{
127+ {ID: "first", Kind: BlockKind("paragraph")},
128+ {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2},
129+ })
130+
131+ if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) {
132+ t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok)
133+ }
134+
135+ if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) {
136+ t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok)
137+ }
138+
139+ if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) {
140+ t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok)
141+ }
142+ })
143+}
144+
145+func TestStrictNextHeading(t *testing.T) {
146+ doc := headingMotionDocument(t)
147+
148+ tests := []struct {
149+ name string
150+ from BlockID
151+ count int
152+ levels []int
153+ want Cursor
154+ ok bool
155+ }{
156+ {
157+ name: "from non heading uses next heading",
158+ from: "p0",
159+ want: Cursor{BlockID: "h1"},
160+ ok: true,
161+ },
162+ {
163+ name: "count below one means one",
164+ from: "p1",
165+ count: -3,
166+ want: Cursor{BlockID: "h2"},
167+ ok: true,
168+ },
169+ {
170+ name: "strict next does not match current heading",
171+ from: "h1",
172+ count: 1,
173+ want: Cursor{BlockID: "h2"},
174+ ok: true,
175+ },
176+ {
177+ name: "count selects nth next heading",
178+ from: "h1",
179+ count: 2,
180+ want: Cursor{BlockID: "h3"},
181+ ok: true,
182+ },
183+ {
184+ name: "level filter skips other headings",
185+ from: "h1",
186+ count: 1,
187+ levels: []int{1},
188+ want: Cursor{BlockID: "h1b"},
189+ ok: true,
190+ },
191+ {
192+ name: "count applies after filtering",
193+ from: "h1",
194+ count: 2,
195+ levels: []int{2},
196+ want: Cursor{BlockID: "h2b"},
197+ ok: true,
198+ },
199+ {
200+ name: "multi level filter matches any listed level",
201+ from: "h2",
202+ count: 1,
203+ levels: []int{1, 3},
204+ want: Cursor{BlockID: "h3"},
205+ ok: true,
206+ },
207+ {
208+ name: "no matching level returns false",
209+ from: "p0",
210+ levels: []int{6},
211+ },
212+ {
213+ name: "past last heading returns false",
214+ from: "h1b",
215+ },
216+ {
217+ name: "missing cursor returns false",
218+ from: "missing",
219+ },
220+ }
221+
222+ for _, tt := range tests {
223+ t.Run(tt.name, func(t *testing.T) {
224+ before := doc.Blocks()
225+ got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
226+ if ok != tt.ok || got != tt.want {
227+ t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
228+ }
229+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
230+ t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after)
231+ }
232+ })
233+ }
234+}
235+
236+func TestStrictPreviousHeading(t *testing.T) {
237+ doc := headingMotionDocument(t)
238+
239+ tests := []struct {
240+ name string
241+ from BlockID
242+ count int
243+ levels []int
244+ want Cursor
245+ ok bool
246+ }{
247+ {
248+ name: "from non heading uses previous heading",
249+ from: "p2",
250+ want: Cursor{BlockID: "h2b"},
251+ ok: true,
252+ },
253+ {
254+ name: "count below one means one",
255+ from: "p2",
256+ count: 0,
257+ want: Cursor{BlockID: "h2b"},
258+ ok: true,
259+ },
260+ {
261+ name: "strict previous does not match current heading",
262+ from: "h1b",
263+ count: 1,
264+ want: Cursor{BlockID: "h2b"},
265+ ok: true,
266+ },
267+ {
268+ name: "count selects nth previous heading",
269+ from: "h1b",
270+ count: 2,
271+ want: Cursor{BlockID: "h3"},
272+ ok: true,
273+ },
274+ {
275+ name: "level filter skips other headings",
276+ from: "h1b",
277+ count: 1,
278+ levels: []int{1},
279+ want: Cursor{BlockID: "h1"},
280+ ok: true,
281+ },
282+ {
283+ name: "count applies after filtering",
284+ from: "h1b",
285+ count: 2,
286+ levels: []int{2},
287+ want: Cursor{BlockID: "h2"},
288+ ok: true,
289+ },
290+ {
291+ name: "multi level filter matches any listed level",
292+ from: "h2b",
293+ count: 1,
294+ levels: []int{1, 3},
295+ want: Cursor{BlockID: "h3"},
296+ ok: true,
297+ },
298+ {
299+ name: "no matching level returns false",
300+ from: "h1b",
301+ levels: []int{6},
302+ },
303+ {
304+ name: "before first heading returns false",
305+ from: "h1",
306+ },
307+ {
308+ name: "missing cursor returns false",
309+ from: "missing",
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ before := doc.Blocks()
316+ got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
317+ if ok != tt.ok || got != tt.want {
318+ t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
319+ }
320+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
321+ t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after)
322+ }
323+ })
324+ }
325+}
326+
327+func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) {
328+ tests := []struct {
329+ name string
330+ blocks []Block
331+ from Cursor
332+ }{
333+ {
334+ name: "empty document",
335+ from: Cursor{BlockID: "missing"},
336+ },
337+ {
338+ name: "document without headings",
339+ blocks: []Block{
340+ {ID: "p0", Kind: BlockKind("paragraph")},
341+ {ID: "p1", Kind: BlockKind("list_item")},
342+ },
343+ from: Cursor{BlockID: "p0"},
344+ },
345+ }
346+
347+ for _, tt := range tests {
348+ t.Run(tt.name, func(t *testing.T) {
349+ doc := mustDocument(t, tt.blocks)
350+
351+ if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) {
352+ t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok)
353+ }
354+ if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) {
355+ t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok)
356+ }
357+ })
358+ }
359+}
360+
361+func headingMotionDocument(t *testing.T) Document {
362+ t.Helper()
363+
364+ return mustDocument(t, []Block{
365+ {ID: "p0", Kind: BlockKind("paragraph")},
366+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
367+ {ID: "p1", Kind: BlockKind("paragraph")},
368+ {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
369+ {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
370+ {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
371+ {ID: "p2", Kind: BlockKind("paragraph")},
372+ {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
373+ })
374+}
375+
376+func mustDocument(t *testing.T, blocks []Block) Document {
377+ t.Helper()
378+
379+ doc, err := NewDocument(blocks)
380+ if err != nil {
381+ t.Fatalf("NewDocument() error = %v", err)
382+ }
383+ return doc
384+}
A internal/domain/docmodel/motions.go +56 -0
@@ -0,0 +1,56 @@
1+package docmodel
2+
3+// NextHeading returns the cursor for the count-th heading after from. Motions
4+// are strict: a heading at from is not considered a match. A count less than or
5+// equal to zero is treated as one. When levels are provided, only headings with
6+// one of those levels match.
7+func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
8+ return d.headingMotion(from, count, 1, levels)
9+}
10+
11+// PreviousHeading returns the cursor for the count-th heading before from.
12+// Motions are strict: a heading at from is not considered a match. A count less
13+// than or equal to zero is treated as one. When levels are provided, only
14+// headings with one of those levels match.
15+func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
16+ return d.headingMotion(from, count, -1, levels)
17+}
18+
19+func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
20+ start, ok := d.indexByID[from.BlockID]
21+ if !ok {
22+ return Cursor{}, false
23+ }
24+
25+ if count <= 0 {
26+ count = 1
27+ }
28+
29+ remaining := count
30+ for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
31+ block := d.blocks[i]
32+ if !block.IsHeading() || !headingLevelMatches(block.HeadingLevel, levels) {
33+ continue
34+ }
35+
36+ remaining--
37+ if remaining == 0 {
38+ return Cursor{BlockID: block.ID}, true
39+ }
40+ }
41+
42+ return Cursor{}, false
43+}
44+
45+func headingLevelMatches(level int, levels []int) bool {
46+ if len(levels) == 0 {
47+ return true
48+ }
49+
50+ for _, allowed := range levels {
51+ if level == allowed {
52+ return true
53+ }
54+ }
55+ return false
56+}
@@ -0,0 +1,56 @@
1+package docmodel
2+
3+// NextHeading returns the cursor for the count-th heading after from. Motions
4+// are strict: a heading at from is not considered a match. A count less than or
5+// equal to zero is treated as one. When levels are provided, only headings with
6+// one of those levels match.
7+func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
8+ return d.headingMotion(from, count, 1, levels)
9+}
10+
11+// PreviousHeading returns the cursor for the count-th heading before from.
12+// Motions are strict: a heading at from is not considered a match. A count less
13+// than or equal to zero is treated as one. When levels are provided, only
14+// headings with one of those levels match.
15+func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
16+ return d.headingMotion(from, count, -1, levels)
17+}
18+
19+func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
20+ start, ok := d.indexByID[from.BlockID]
21+ if !ok {
22+ return Cursor{}, false
23+ }
24+
25+ if count <= 0 {
26+ count = 1
27+ }
28+
29+ remaining := count
30+ for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
31+ block := d.blocks[i]
32+ if !block.IsHeading() || !headingLevelMatches(block.HeadingLevel, levels) {
33+ continue
34+ }
35+
36+ remaining--
37+ if remaining == 0 {
38+ return Cursor{BlockID: block.ID}, true
39+ }
40+ }
41+
42+ return Cursor{}, false
43+}
44+
45+func headingLevelMatches(level int, levels []int) bool {
46+ if len(levels) == 0 {
47+ return true
48+ }
49+
50+ for _, allowed := range levels {
51+ if level == allowed {
52+ return true
53+ }
54+ }
55+ return false
56+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close