-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlint.go
More file actions
390 lines (368 loc) · 17.9 KB
/
Copy pathlint.go
File metadata and controls
390 lines (368 loc) · 17.9 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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
package waxlabel
import (
"fmt"
"slices"
"strings"
"time"
"github.com/colespringer/waxlabel/internal/core"
"github.com/colespringer/waxlabel/tag"
)
// LintSeverity grades a [Finding].
type LintSeverity uint8
const (
// LintInfo notes something worth knowing but not wrong.
LintInfo LintSeverity = iota
// LintWarning flags a likely problem (stale legacy tags, encoder noise).
LintWarning
// LintError flags an invalid or contradictory state.
LintError
)
func (s LintSeverity) String() string {
switch s {
case LintError:
return "error"
case LintWarning:
return "warning"
default:
return "info"
}
}
// Finding is one issue reported by [Document.Lint].
type Finding struct {
Severity LintSeverity
Code string
Message string
Key tag.Key // the field involved, or "" if not field-specific
}
// String renders the finding as "[severity] code: message (key)". The severity and
// code are fixed library vocabulary; the message and key can be file-derived (the
// inherited-encoder message carries the raw inherited stamp; a custom-key finding
// carries the raw field name), so those two are run through [tag.SanitizeLine]
// individually - the finding prints as one list item, so a newline or tab is
// escaped too (it cannot forge a line), not just the terminal-hijack class. A
// library consumer that prints this without the CLI's output boundary is then safe.
// The malformed-date message is already %q-escaped inside the Message, which
// SanitizeLine leaves intact (no double-escape).
func (f Finding) String() string {
msg := tag.SanitizeLine(f.Message)
if f.Key != "" {
return fmt.Sprintf("[%s] %s: %s (%s)", f.Severity, f.Code, msg, tag.SanitizeLine(string(f.Key)))
}
return fmt.Sprintf("[%s] %s: %s", f.Severity, f.Code, msg)
}
// Lint inspects a document for issues a tagger would want to surface or fix:
// stale legacy containers, inherited encoder noise, conflicting family values,
// duplicate or invalid pictures, chapters that collide or start past the audio,
// malformed dates and numbers, single-valued keys carrying several values,
// custom (non-vocabulary) keys, a tag entry the container holds but no reader can
// interpret, and a chunk whose declared size the container leaves unknown. It reads
// only the parsed document (no I/O) and never modifies it.
func (d *Document) Lint() []Finding {
if d.zero() {
return nil
}
var out []Finding
out = append(out, lintWarnings(d.media.Warnings)...)
out = append(out, lintFamilies(d.media.Families)...)
out = append(out, lintLegacyOnly(d.LegacyOnlyKeys())...)
out = append(out, lintOpaqueLegacy(d.media.LegacyOpaqueContent)...)
// Lint the display projection so a cover whose bytes disagree with its stored MIME (a GIF
// mislabeled image/png, or junk under a valid-looking label) is judged by its real type - the
// unrecognized-MIME and format checks then see what a reader would. media.Pictures stays stored
// for the write path; the projection is a read-only view.
out = append(out, lintPictures(core.ProjectPictures(d.media.Pictures))...)
out = append(out, lintChapters(d.media.Chapters, d.media.Properties.Duration())...)
out = append(out, lintValues(d.media.Tags)...)
out = append(out, lintNegativeNumbers(d.media.Tags)...)
out = append(out, lintCardinality(d.media.Tags)...)
out = append(out, lintCustomKeys(d.media.Tags)...)
return out
}
// lintWarnings promotes the parse-time warnings that a tagger usually acts on. Each
// promoted warning reuses w.Code.String() as its finding code, so a condition that
// both dump (which prints the warning code) and lint surface reads with the same code
// in each - no renamed alias to keep in sync. Only the subset a tagger acts on is
// promoted (other parse warnings are informational); the per-condition severity is the
// only thing this assigns. The computed-only lint codes that dump never prints
// (malformed-date, single-valued-multi, custom-key, the picture checks) are added by
// the sibling lint* helpers, not here.
func lintWarnings(ws []core.Warning) []Finding {
var out []Finding
for _, w := range ws {
switch w.Code {
case core.WarnStrayLeadingID3, core.WarnTrailingID3v1, core.WarnLegacyAPE,
core.WarnInheritedEncoder, core.WarnInvalidPicture, core.WarnTruncatedAudio,
core.WarnInvalidTagKey, core.WarnChainedStream, core.WarnTrailingBytes,
core.WarnOversizedChunk, core.WarnMalformedTagEntry:
out = append(out, Finding{LintWarning, w.Code.String(), w.Message, ""})
case core.WarnMultipleVorbisComment, core.WarnDuplicateTagBlock, core.WarnNoAudioFrames:
out = append(out, Finding{LintError, w.Code.String(), w.Message, ""})
case core.WarnNumericGenre, core.WarnUnknownChunkSize:
// Informational, like negative-numeric/custom-key: worth surfacing in lint
// (README promises dump and lint both report it) without flipping the clean
// exit. A numeric genre reference resolved to a name, and a size-unknown chunk
// is what a non-seekable writer legitimately emits - so a piped WAV capture
// must not fail lint, though what the sentinel costs the reader (anything
// after the chunk) is still worth reporting.
out = append(out, Finding{LintInfo, w.Code.String(), w.Message, ""})
}
}
return out
}
// lintChapters reports the two chapter defects a tagger acts on, whoever wrote them:
// two chapters sharing a start (navigation lands on only one) and a chapter starting
// past the file's playable length (usually a mistyped timestamp). The editor raises the
// same pair on the chapters an edit introduces; this is the answer for a file WaxLabel
// did not write, which is what set --help points at when it says to lint the saved file.
//
// Both rules come from [core.ChaptersPastDuration] and [core.DuplicateChapterStarts],
// which the editor calls too, so the file view and the edit view cannot come to differ on
// what a defect is - only on which chapters they ask about. The unknown-duration gate
// (a truncated or header-only file reports 0, which would otherwise flag every chapter as
// beyond 0:00) lives in that shared rule rather than being restated here.
func lintChapters(chapters []core.Chapter, duration time.Duration) []Finding {
var out []Finding
for _, c := range core.ChaptersPastDuration(chapters, duration) {
out = append(out, Finding{LintWarning, core.WarnChapterPastDuration.String(),
core.ChapterPastDurationMessage(c.Start, duration), ""})
}
for _, start := range core.DuplicateChapterStarts(chapters) {
out = append(out, Finding{LintWarning, core.WarnDuplicateChapter.String(),
core.DuplicateChapterMessage(start), ""})
}
return out
}
// lintFamilies reports canonical keys whose source fields disagree (a value was
// not selected because multiple native fields supplied conflicting values). A key
// is reported once even when several of its family entries are unselected: one
// conflict per key, so a consumer counting findings does not double-count a single
// disagreement (the parse warning already surfaces it once). The wording is the shared
// [core.ConflictingFamiliesMessage] - the same one the parser's conflicting-families
// warning uses - so dump and lint read identically; the key lives in the Finding.Key
// field (kept structured for JSON consumers, like the other key-specific findings), and
// Finding.String renders it as the " (KEY)" suffix the dump warning appends inline.
func lintFamilies(fams []core.FamilyValue) []Finding {
var out []Finding
seen := map[tag.Key]bool{}
for _, f := range fams {
if f.Selected || seen[f.Key] {
continue
}
seen[f.Key] = true
out = append(out, Finding{
LintWarning, "conflicting-families",
core.ConflictingFamiliesMessage(), f.Key,
})
}
return out
}
// lintLegacyOnly reports canonical keys whose value lives only in a legacy container (see
// [Document.LegacyOnlyKeys]). It is LintInfo so it does not flip the clean exit: the value is
// preserved, not lost, and the pre-existing legacy-container warning already carries the
// LintWarning severity. This finding explains why lint --fix intentionally leaves the container
// in place - the values would be destroyed by a strip - and points at dump --native to see them.
func lintLegacyOnly(keys []tag.Key) []Finding {
if len(keys) == 0 {
return nil
}
return []Finding{{LintInfo, "legacy-only-tags",
fmt.Sprintf("%d tag(s) present only in a legacy container; see dump --native", len(keys)), ""}}
}
// lintOpaqueLegacy reports that a legacy container holds non-tag content the canonical view does
// not fold in (an APEv2 binary item, a leading ID3v2's pictures/chapters/lyrics, or an unreadable
// such container). It is LintInfo, like lintLegacyOnly: nothing is lost, the pre-existing legacy
// warning already carries the LintWarning severity. It explains why lint --fix intentionally leaves
// the container in place - a strip would destroy content that lives nowhere else - completing the
// symmetry with the legacy-only-tags finding, which covers unique tags rather than non-tag content.
func lintOpaqueLegacy(opaque bool) []Finding {
if !opaque {
return nil
}
return []Finding{{LintInfo, "legacy-opaque-content",
"a legacy container holds non-tag content (picture, chapter, or binary item) not shown; see dump --native", ""}}
}
// duplicatePictureMessage and multipleFrontCoversMessage are the shared human
// messages for the duplicate-picture and multiple-front-covers conditions, so the
// linter's whole-set finding (lintPictures) and the editor's edit-scoped plan warning
// (appendPictureWarnings) read identically - only their scope differs, not the
// wording, so a reword cannot make the two silently disagree on the same file.
func duplicatePictureMessage(roles []core.PictureType) string {
// Name the message by the sorted set of roles the identical bytes appear under, not a single
// occurrence's role: the linter scans the whole parsed set and the editor scans the edit
// scope, so "first vs second occurrence" is not a shared concept and naming one would make
// the two disagree when the shared bytes carry different Types. A single role (the common
// case) keeps the original wording; multiple roles list them all in a stable order.
if len(roles) <= 1 {
var t core.PictureType
if len(roles) == 1 {
t = roles[0]
}
return fmt.Sprintf("identical %s picture appears more than once", t)
}
names := make([]string, len(roles))
for i, r := range roles {
names[i] = r.String()
}
return fmt.Sprintf("identical picture appears more than once (roles: %s)", strings.Join(names, ", "))
}
// distinctSortedRoles returns the distinct picture types among pics whose bytes hash to one of
// the given per-index hashes equal to h, sorted, so a duplicate-picture message names every role
// the identical bytes appear under in a stable, iteration-order-independent way. hashes[i] is the
// precomputed hash of pics[i] (a site may only hash a length-matching subset; an index absent
// from hashes is skipped).
func distinctSortedRoles(pics []Picture, hashes map[int][32]byte, h [32]byte) []core.PictureType {
var roles []core.PictureType
for i := range pics {
if hashes[i] == h && !slices.Contains(roles, pics[i].Type) {
roles = append(roles, pics[i].Type)
}
}
slices.Sort(roles)
return roles
}
func multipleFrontCoversMessage(fronts int) string {
return fmt.Sprintf("%d front-cover pictures", fronts)
}
// lintPictures reports duplicate covers, redundant front covers, and the
// single-icon rule.
func lintPictures(pics []Picture) []Finding {
var out []Finding
// Precompute every picture's hash once, so a duplicate finding can name the whole set of
// roles the identical bytes appear under (distinctSortedRoles) rather than a single
// occurrence's role - keeping the message identical to the editor's edit-scope warning.
hashes := make(map[int][32]byte, len(pics))
for i, p := range pics {
hashes[i] = p.Hash()
}
seen := map[[32]byte]bool{}
fronts := 0
for i, p := range pics {
// A picture the codec could not sniff is stored as the unrecognized-image MIME;
// key on that (not a re-sniff) so a cover a codec already recognized is never
// false-flagged. Reported only - never auto-fixed - since a valid but
// unsniffable cover (WebP/AVIF) degrades to exactly this, and dropping it
// would be silent data loss.
if p.Unrecognized() {
out = append(out, Finding{LintWarning, "invalid-picture",
fmt.Sprintf("%s picture is not a recognized image type (%s)", p.Type, p.MIME), ""})
}
if reason, bad := core.NonConformingIcon(p); bad {
// The code comes from the warning's own String, not a literal: the edit-time
// warning and this finding are documented to report the same condition under the
// same code, and a hand-written copy would let a rename split them silently.
out = append(out, Finding{LintWarning, core.WarnNonConformingIcon.String(), reason, ""})
}
h := hashes[i]
if seen[h] {
out = append(out, Finding{LintWarning, "duplicate-picture", duplicatePictureMessage(distinctSortedRoles(pics, hashes, h)), ""})
}
seen[h] = true
if p.Type == core.PicFrontCover {
fronts++
}
}
if fronts > 1 {
out = append(out, Finding{LintWarning, "multiple-front-covers", multipleFrontCoversMessage(fronts), ""})
}
// LintError, not the LintWarning non-conforming-icon gets: two type-1 pictures make the
// frame set ambiguous and unrepairable without choosing one, while an oversized icon is
// unambiguous and every reader renders it. Do not "fix" the asymmetry.
if icon, otherIcon := core.CountIcons(pics); icon > 1 || otherIcon > 1 {
out = append(out, Finding{LintError, "duplicate-icon",
"picture types 1/2 must be unique", ""})
}
return out
}
// lintValues reports tag values that violate their key's typed contract, driven by
// the shared [tag.ValidatorFor] registry so the linter and the CLI's set-time note
// ([noteMalformedValue]) apply exactly the same rule per category - numeric, date,
// boolean, the MP4-integer keys (non-negative ints: MEDIATYPE, ITUNESADVISORY, and
// the movement pair), BPM (a non-negative decimal), ReplayGain (a decimal/dB), the R128
// gains (a signed 16-bit integer), and RELEASECOUNTRY (a two-letter code). This is the
// single source the "lint and set agree" contract needs: it folds in the former
// lintDates/lintNumbers and closes the gap where COMPILATION was set-validated but not
// lint-validated, and MEDIATYPE/REPLAYGAIN at neither. A present-but-empty value is
// skipped (set blesses it as the benign "empty value" advisory and writes it, so lint
// must agree); RATING is uncovered (free-form across formats). Each finding is a
// LintWarning, so a file with e.g. TRACKNUMBER=abc flips to a non-zero lint exit (a
// deliberate expansion of lint coverage). Iterating the key names and Get-ing only
// the keys with a contract (mirroring the prior helpers) clones at most those few value
// slices, not the whole set.
func lintValues(ts tag.TagSet) []Finding {
var out []Finding
for _, k := range ts.Keys() {
val, ok := tag.ValidatorFor(k)
if !ok {
continue
}
vals, _ := ts.Get(k)
for _, v := range vals {
// Trim the value the same way set does before it validates, so lint and set cannot
// disagree on a whitespace-only or space-padded trimmable value. A whitespace-only
// numeric (" ") trims to empty and is skipped as the benign empty-value case set
// writes; a space-padded number (" 3 ") validates on its trimmed form. TrimTokenValue
// early-returns for a non-trimmable key, so those validators see the value unchanged.
v = tag.TrimTokenValue(k, v)
if v != "" && !val.Valid(k, v) {
detail, _ := val.Details(k, v)
out = append(out, Finding{LintWarning, val.LintCode,
fmt.Sprintf("%q %s", v, detail), k})
}
}
}
return out
}
// lintNegativeNumbers reports numeric fields with negative values, such as a negative
// track number or play count. These values parse and round-trip, but they are usually
// mistakes. This mirrors the set-time advisory using the same predicate and stays
// LintInfo, like custom-key, so it does not change the clean/non-clean exit boundary.
// Present-but-empty values are skipped.
func lintNegativeNumbers(ts tag.TagSet) []Finding {
var out []Finding
for _, k := range ts.Keys() {
if !tag.IsNumericKey(k) {
continue
}
vals, _ := ts.Get(k)
for _, v := range vals {
if v != "" && tag.NegativeNumericValue(k, v) {
out = append(out, Finding{LintInfo, "negative-numeric",
fmt.Sprintf("%q is negative (numbering is normally non-negative)", v), k})
}
}
}
return out
}
// lintCardinality reports known keys that canonically hold a single value but carry
// more than one - e.g. a transcoded file projecting ENCODER to a muxer value plus a
// codec value across two Matroska scopes. The typed accessor would silently read
// only the first, so surfacing the duplication keeps that lossiness visible. A
// multi-valued key (artist, genre, ...) is exempt, and so is a custom (unknown)
// key: it has no typed accessor, so its values are read back in full via
// TagSet.Get, and it is already reported by the custom-key rule. Flagging it here
// would be a false positive (multiple values in a custom field are legitimate).
func lintCardinality(ts tag.TagSet) []Finding {
var out []Finding
for k, vals := range ts.All() {
if k.SingleValuedMulti(len(vals)) {
out = append(out, Finding{LintWarning, "single-valued-multi",
fmt.Sprintf("single-valued key holds %d values", len(vals)), k})
}
}
return out
}
// lintCustomKeys reports keys outside the published canonical vocabulary. A custom
// field round-trips faithfully, so this is informational, never a warning: it
// never flips a clean file to a non-zero exit, it just tells a tagger which fields
// are non-standard. The R128 loudness keys are exempt: they are outside the vocabulary
// because they describe the file's own audio rather than its metadata, but RFC 7845
// defines them, so calling them non-standard would be wrong.
func lintCustomKeys(ts tag.TagSet) []Finding {
var out []Finding
for _, k := range ts.Keys() {
if !k.Known() && !tag.IsR128GainKey(k) {
out = append(out, Finding{LintInfo, "custom-key", "custom field, not a known key", k})
}
}
return out
}