-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathlinkgraph.go
More file actions
277 lines (255 loc) · 8.46 KB
/
Copy pathlinkgraph.go
File metadata and controls
277 lines (255 loc) · 8.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
// Package linkgraph extracts Markdown links and heading anchors so the
// link-validity rule (MDS027) and the `backlinks` subcommand share one
// implementation of the link walk, anchor slug rules, and target
// parsing.
package linkgraph
import (
"net/url"
"strings"
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/mdtext"
)
// Target is the parsed shape of a link destination URL.
//
// Raw is the original destination string as it appeared in the source.
// Path and Anchor are the decoded path and fragment components — both
// are populated from url.URL, which percent-decodes them on parse.
// LocalAnchor is true when the destination was an anchor-only
// reference (e.g. `#section`).
//
// Anchor matching against CollectAnchors output must still go through
// NormalizeAnchor: that runs Slugify (and a defensive PathUnescape) to
// produce the same form CollectAnchors stores.
type Target struct {
Raw string
Path string
Anchor string
LocalAnchor bool
}
// ParseTarget parses a Markdown link destination into a Target.
// Returns ok=false when the destination is empty, has a scheme or
// host (treated as external), or has neither a path nor a fragment.
func ParseTarget(dest string) (Target, bool) {
dest = strings.TrimSpace(dest)
if dest == "" || strings.HasPrefix(dest, "//") {
return Target{}, false
}
u, err := url.Parse(dest)
if err != nil {
return Target{}, false
}
if u.Scheme != "" || u.Host != "" {
return Target{}, false
}
// u.Opaque is non-empty only on URLs with a scheme; the scheme
// check above already short-circuits that case, so we can read
// the path component directly.
path := u.Path
if path == "" && u.Fragment != "" {
return Target{
Raw: dest,
Anchor: u.Fragment,
LocalAnchor: true,
}, true
}
if path == "" {
return Target{}, false
}
return Target{
Raw: dest,
Path: path,
Anchor: u.Fragment,
}, true
}
// Link is one parsed Markdown link occurrence in a source file.
//
// Reference-style links (`[text][label]`) are intentionally omitted
// from ExtractLinks results because their destinations resolve through
// the link-reference map rather than a URL; the link-graph builder
// only sees direct destinations.
//
// Line is body-relative — counted from the start of the parsed body,
// not the original file. Lint rules return body-relative diagnostics
// because the engine applies f.LineOffset for front-matter adjustment.
// CLI callers (like `mdsmith list backlinks`) that want file-relative line
// numbers must add f.LineOffset themselves.
type Link struct {
Line int
Column int
Text string
Target Target
}
// Links returns every regular Markdown link in document order, memoized
// on the per-Check File. Two calls on the same File return the same
// backing slice; callers must treat it as read-only. The result is
// byte-identical to ExtractLinks. nil is returned for a nil or AST-less
// File, matching ExtractLinks.
//
// Memoized via File.MemoFile (the *File-passing variant of Memo):
// buildLinks is a package-level function, not a closure, so the call
// adds no per-Memo-call heap allocation beyond the cold-path memoEntry.
func Links(f *lint.File) []Link {
if f == nil {
return nil
}
links, _ := f.MemoFile("linkgraph.links", buildLinks).([]Link)
return links
}
// buildLinks is the MemoFile-style builder for the Links memo. Defined at
// package scope so the value passed to MemoFile is a plain function
// pointer (no closure capturing f), avoiding the per-call closure allocation.
func buildLinks(f *lint.File) any {
return ExtractLinks(f)
}
// Images returns every Markdown image in document order, memoized on the
// per-Check File. Two calls on the same File return the same backing slice;
// callers must treat it as read-only. The result is byte-identical to
// ExtractImages. nil is returned for a nil or AST-less File.
//
// Memoized via File.MemoFile: buildImages is a package-level function so
// the call adds no per-Memo-call heap allocation beyond the cold-path
// memoEntry.
func Images(f *lint.File) []Link {
if f == nil {
return nil
}
links, _ := f.MemoFile("linkgraph.images", buildImages).([]Link)
return links
}
// buildImages is the MemoFile-style builder for the Images memo.
func buildImages(f *lint.File) any {
return ExtractImages(f)
}
// ExtractLinks walks f.AST and returns every regular Markdown link in
// document order. Lines are body-relative (post front-matter strip);
// see the Link doc for why.
func ExtractLinks(f *lint.File) []Link {
if f == nil || f.AST == nil {
return nil
}
var out []Link
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
l, ok := n.(*ast.Link)
if !ok {
return ast.WalkContinue, nil
}
// Reference-style links carry l.Reference; the link-graph
// builder skips them so callers see one shape per link.
if l.Reference != nil {
return ast.WalkContinue, nil
}
target, ok := ParseTarget(string(l.Destination))
if !ok {
return ast.WalkContinue, nil
}
line, col := linkPosition(f, l)
out = append(out, Link{
Line: line,
Column: col,
Text: linkText(l, f.Source),
Target: target,
})
return ast.WalkContinue, nil
})
return out
}
// ExtractImages walks f.AST and returns every Markdown image in
// document order. Both inline (Reference == nil) and reference-style
// (Reference != nil) images are included when their destination can
// be parsed as a local target. Lines are body-relative — same
// convention as Link.
func ExtractImages(f *lint.File) []Link {
if f == nil || f.AST == nil {
return nil
}
var out []Link
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
img, ok := n.(*ast.Image)
if !ok {
return ast.WalkContinue, nil
}
target, ok := ParseTarget(string(img.Destination))
if !ok {
return ast.WalkContinue, nil
}
line, col := linkPosition(f, img)
out = append(out, Link{
Line: line,
Column: col,
Text: mdtext.ExtractPlainText(img, f.Source),
Target: target,
})
return ast.WalkContinue, nil
})
return out
}
// CollectAnchors returns the set of heading anchors defined in f, with
// GitHub-compatible disambiguation suffixes (-1, -2, …) when slugs
// would otherwise collide. Uniqueness is enforced against the running
// set of produced anchors so a sequence like "Intro" / "Intro" /
// "Intro-1" yields three distinct keys (`intro`, `intro-1`,
// `intro-1-1`) rather than two distinct ones with a collision.
// The set keys are the slugified anchor names; values are struct{}
// so callers must use the comma-ok idiom: _, ok := anchors[key].
func CollectAnchors(f *lint.File) map[string]struct{} {
anchors := make(map[string]struct{})
if f == nil || f.AST == nil {
return anchors
}
for _, item := range mdtext.CollectTOCItems(f.AST, f.Source) {
anchors[item.Anchor] = struct{}{}
}
return anchors
}
// NormalizeAnchor URL-decodes raw and slugifies it so the result can
// be compared against CollectAnchors output.
func NormalizeAnchor(raw string) string {
if decoded, err := url.PathUnescape(raw); err == nil {
raw = decoded
}
return mdtext.Slugify(raw)
}
// linkText returns the visible link text (everything between `[` and
// `]`). Image alt text and emphasis are flattened to plain text so
// JSON/text output stays readable.
func linkText(link *ast.Link, source []byte) string {
return mdtext.ExtractPlainText(link, source)
}
// linkPosition returns the 1-based source line and column of a link
// node, in body-relative coordinates (no f.LineOffset applied — see
// the Link doc for why).
func linkPosition(f *lint.File, n ast.Node) (int, int) {
offset := firstTextOffset(n)
if offset < 0 {
return 1, 1
}
// f.ColumnOfOffset binary-searches the cached newline index, so
// it's O(log lines) per call instead of the O(column) backward scan
// a hand-rolled version would do — meaningful for `mdsmith list
// backlinks` which can call this many times per file.
return f.LineOfOffset(offset), f.ColumnOfOffset(offset)
}
func firstTextOffset(n ast.Node) int {
offset := -1
_ = ast.Walk(n, func(cur ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
text, ok := cur.(*ast.Text)
if !ok {
return ast.WalkContinue, nil
}
if offset == -1 || text.Segment.Start < offset {
offset = text.Segment.Start
}
return ast.WalkContinue, nil
})
return offset
}