1import type { MigrationAnnotation } from "./Annotations.ts"
2import type { ApiChange, ApiDiff, ChangeClassification } from "./Model.ts"
3
4const breakingClassifications = new Set<ChangeClassification>([
5 "bucket-changed",
6 "declaration-kind-changed",
7 "overload-removed",
8 "overload-reordered",
9 "parameter-removed",
10 "parameter-reordered",
11 "parameter-changed",
12 "return-type-changed",
13 "generic-parameter-changed",
14 "member-removed",
15 "member-changed",
16 "heritage-changed",
17 "union-member-changed",
18 "intersection-member-changed",
19 "structural-change"
20])
21
22const stableApiId = (id: string): string => id.replace(/#(?:type|value)$/, "")
23
24const displayApiId = (id: string): string => {
25 const separator = id.indexOf("#")
26 if (separator === -1) {
27 return id
28 }
29 const module = id.slice(0, separator).split("/").at(-1)!
30 return `${module}.${id.slice(separator + 1)}`
31}
32
33const backtickDelimiter = (value: string, minimum: number): string => {
34 let length = minimum
35 for (const match of value.matchAll(/`+/g)) {
36 length = Math.max(length, match[0].length + 1)
37 }
38 return "`".repeat(length)
39}
40
41const codeSpan = (value: string): string => {
42 const delimiter = backtickDelimiter(value, 1)
43 const content = value.startsWith("`") || value.endsWith("`") ? ` ${value} ` : value
44 return `${delimiter}${content}${delimiter}`
45}
46
47const escapeMarkdownText = (value: string): string => value.replace(/\\/g, "\\\\").replace(/[~<>*_]/g, "\\$&")
48
49const escapeAnnotationText = (value: string): string => {
50 const output: Array<string> = []
51 let index = 0
52 while (index < value.length) {
53 const start = value.indexOf("`", index)
54 if (start === -1) {
55 output.push(escapeMarkdownText(value.slice(index)))
56 break
57 }
58 output.push(escapeMarkdownText(value.slice(index, start)))
59 let end = start + 1
60 while (value[end] === "`") {
61 end++
62 }
63 const delimiter = value.slice(start, end)
64 const close = value.indexOf(delimiter, end)
65 if (close === -1) {
66 output.push("\\`".repeat(delimiter.length))
67 index = end
68 } else {
69 output.push(value.slice(start, close + delimiter.length))
70 index = close + delimiter.length
71 }
72 }
73 return output.join("")
74}
75
76const compareStrings = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
77
78const isBreakingChange = (change: ApiChange): boolean => {
79 if (change.classification !== "parameter-added") {
80 return breakingClassifications.has(change.classification)
81 }
82 const delta = change.delta as {
83 readonly before?: ReadonlyArray<unknown>
84 readonly after?: ReadonlyArray<{ readonly optional?: boolean; readonly rest?: boolean }>
85 } | undefined
86 const beforeLength = delta?.before?.length ?? 0
87 return delta?.after?.slice(beforeLength).some((parameter) => !parameter.optional && !parameter.rest) ?? true
88}
89
90interface MigrationEntry {
91 readonly id: string
92 readonly module: string
93 readonly rename: ApiChange | undefined
94}
95
96const importMapReplacements = (importMapSections: string): ReadonlyMap<string, ReadonlyArray<string>> => {
97 const replacements = new Map<string, Array<string>>()
98 for (const line of importMapSections.split(/\r?\n/)) {
99 const match = /^(\S+) -> (\S+)(?: \(barrel: [^)]+\))?$/.exec(line)
100 if (match === null) {
101 continue
102 }
103 const targets = replacements.get(match[1]!)
104 if (targets === undefined) {
105 replacements.set(match[1]!, [match[2]!])
106 } else if (!targets.includes(match[2]!)) {
107 targets.push(match[2]!)
108 }
109 }
110 return replacements
111}
112
113const removedModules = (diff: ApiDiff): ReadonlyArray<string> =>
114 [
115 ...new Set(diff.changes.flatMap((change) => {
116 if (change.classification !== "module-removed") {
117 return []
118 }
119 const from = (change.delta as { readonly from?: unknown } | undefined)?.from
120 return typeof from === "string" ? [from] : []
121 }))
122 ].sort(compareStrings)
123
124const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
125 left.length === right.length && left.every((value, index) => value === right[index])
126
127const addedApiSignatures = (diff: ApiDiff): ReadonlyMap<string, ReadonlyArray<string>> => {
128 const signatures = new Map<string, Array<string>>()
129 for (const change of diff.changes) {
130 if (change.classification !== "api-added" || change.headApiId === undefined || change.after === undefined) {
131 continue
132 }
133 const id = stableApiId(change.headApiId)
134 const group = signatures.get(id)
135 if (group === undefined) {
136 signatures.set(id, [change.after])
137 } else {
138 group.push(change.after)
139 }
140 }
141 for (const group of signatures.values()) {
142 group.sort(compareStrings)
143 }
144 return signatures
145}
146
147const isUnchangedModuleMove = (
148 id: string,
149 changes: ReadonlyArray<ApiChange>,
150 removed: ReadonlySet<string>,
151 replacements: ReadonlyMap<string, ReadonlyArray<string>>,
152 addedSignatures: ReadonlyMap<string, ReadonlyArray<string>>
153): boolean => {
154 const separator = id.indexOf("#")
155 const module = id.slice(0, separator)
156 const targets = replacements.get(module)
157 if (separator === -1 || !removed.has(module) || targets === undefined) {
158 return false
159 }
160 const before = changes
161 .filter((change) => change.classification === "api-removed" && change.before !== undefined)
162 .map((change) => change.before!)
163 .sort(compareStrings)
164 if (before.length === 0) {
165 return false
166 }
167 const path = id.slice(separator + 1)
168 return targets.some((target) => {
169 const after = addedSignatures.get(`${target}#${path}`)
170 return after !== undefined && sameStrings(before, after)
171 })
172}
173
174const isRemovalCoveredByModule = (
175 id: string,
176 removed: ReadonlySet<string>,
177 annotations: ReadonlyMap<string, MigrationAnnotation>
178): boolean => {
179 const separator = id.indexOf("#")
180 const module = id.slice(0, separator)
181 if (separator === -1 || !removed.has(module) || annotations.get(module)?.replacement !== "none") {
182 return false
183 }
184 const annotation = annotations.get(id)
185 return annotation === undefined || annotation.replacement === "none"
186}
187
188const migrationEntries = (
189 diff: ApiDiff,
190 annotations: ReadonlyMap<string, MigrationAnnotation>,
191 importMapSections: string
192): ReadonlyArray<MigrationEntry> => {
193 const grouped = new Map<string, Array<ApiChange>>()
194 for (const change of diff.changes) {
195 if (change.baseApiId === undefined) {
196 continue
197 }
198 const id = stableApiId(change.baseApiId)
199 const changes = grouped.get(id)
200 if (changes === undefined) {
201 grouped.set(id, [change])
202 } else {
203 changes.push(change)
204 }
205 }
206
207 const entries: Array<MigrationEntry> = []
208 const removedModuleSet = new Set(removedModules(diff))
209 const replacements = importMapReplacements(importMapSections)
210 const addedSignatures = addedApiSignatures(diff)
211 for (const [id, changes] of grouped) {
212 const rename = changes.find((change) => change.classification === "api-renamed")
213 const breaking = changes.some(isBreakingChange)
214 const removed = changes.some((change) => change.classification === "api-removed")
215 const importMove = changes.some((change) => change.classification === "api-moved")
216 if (
217 isUnchangedModuleMove(id, changes, removedModuleSet, replacements, addedSignatures) ||
218 (removed && isRemovalCoveredByModule(id, removedModuleSet, annotations)) ||
219 (!breaking && rename === undefined && !removed) ||
220 (importMove && rename === undefined && !breaking && !annotations.has(id))
221 ) {
222 continue
223 }
224 entries.push({
225 id,
226 module: id.split("#")[0]!,
227 rename
228 })
229 }
230 for (const [id, annotation] of annotations) {
231 if (annotation.include === true && id.includes("#") && !entries.some((entry) => entry.id === id)) {
232 entries.push({ id, module: id.split("#")[0]!, rename: undefined })
233 }
234 }
235 return entries.sort((left, right) => compareStrings(left.module, right.module) || compareStrings(left.id, right.id))
236}
237
238const renderDetailedEntry = (
239 entry: MigrationEntry,
240 annotation: MigrationAnnotation,
241 example: string
242): ReadonlyArray<string> => {
243 const fence = backtickDelimiter(example, 3)
244 return [
245 `#### ${codeSpan(displayApiId(entry.id))}`,
246 "",
247 `**Replacement:** ${codeSpan(annotation.replacement)}`,
248 "",
249 escapeAnnotationText(annotation.note),
250 "",
251 "**Example**",
252 "",
253 `${fence}ts`,
254 example,
255 fence,
256 ""
257 ]
258}
259
260const renderCompactEntry = (
261 entry: MigrationEntry,
262 annotation: MigrationAnnotation | undefined
263): string => {
264 if (annotation !== undefined) {
265 return `- ${codeSpan(displayApiId(entry.id))} -> ${codeSpan(annotation.replacement)}: ${
266 escapeAnnotationText(annotation.note)
267 }`
268 }
269 const target = entry.rename?.headApiId
270 return target === undefined
271 ? `- ${codeSpan(displayApiId(entry.id))}: TODO: needs guidance`
272 : `- ${codeSpan(displayApiId(entry.id))} -> ${codeSpan(displayApiId(stableApiId(target)))}: TODO: needs guidance`
273}
274
275export const extractImportMapSections = (document: string): string => {
276 const start = document.indexOf("## Import Map")
277 if (start === -1) {
278 return "## Import Map\n\nNo import map is available.\n"
279 }
280 const apiRenames = document.indexOf("\n## API Renames", start)
281 const removedModules = document.indexOf("\n## Removed Modules", start)
282 const apiReference = document.indexOf("\n## API Reference", start)
283 const ends = [apiRenames, removedModules, apiReference].filter((index) => index !== -1)
284 const end = ends.length === 0 ? document.length : Math.min(...ends)
285 return `${document.slice(start, end).trim()}\n`
286}
287
288export const renderMigrationDocument = (
289 diff: ApiDiff,
290 annotations: ReadonlyMap<string, MigrationAnnotation>,
291 importMapSections: string
292): string => {
293 const entries = migrationEntries(diff, annotations, importMapSections)
294 const replacements = importMapReplacements(importMapSections)
295 const modulesRemoved = removedModules(diff)
296 const modules = new Map<string, Array<MigrationEntry>>()
297 for (const entry of entries) {
298 const group = modules.get(entry.module)
299 if (group === undefined) {
300 modules.set(entry.module, [entry])
301 } else {
302 group.push(entry)
303 }
304 }
305 const lines = [
306 "<!-- dprint-ignore-file -->",
307 "",
308 "# v3 to v4 Migration Reference",
309 "",
310 `Base: \`${diff.base.ref}\` (\`${diff.base.sha}\`)`,
311 "",
312 `Head: \`${diff.head.ref}\` (\`${diff.head.sha}\`)`,
313 "",
314 "This file is generated from the API diff and `migration/annotations/*.yaml`.",
315 "",
316 importMapSections.trim(),
317 "",
318 "## Removed Modules",
319 ""
320 ]
321 for (const module of modulesRemoved) {
322 const annotation = annotations.get(module)
323 const targets = replacements.get(module)
324 if (annotation !== undefined) {
325 lines.push(
326 `- ${codeSpan(module)} -> ${codeSpan(annotation.replacement)}: ${escapeAnnotationText(annotation.note)}`
327 )
328 } else if (targets !== undefined) {
329 lines.push(`- ${codeSpan(module)} -> ${targets.map(codeSpan).join(", ")}`)
330 } else if ([...annotations.keys()].some((id) => id.startsWith(`${module}#`))) {
331 lines.push(`- ${codeSpan(module)}: No single module replacement; follow the curated per-API guidance below.`)
332 } else {
333 lines.push(`- ${codeSpan(module)}: TODO: needs module guidance`)
334 }
335 }
336 if (modulesRemoved.length === 0) {
337 lines.push("No modules were removed.")
338 }
339 lines.push(
340 "",
341 "## API Reference",
342 ""
343 )
344 for (const [module, moduleEntries] of modules) {
345 lines.push(`### ${codeSpan(module)}`, "")
346 for (const entry of moduleEntries) {
347 const annotation = annotations.get(entry.id)
348 if (annotation?.example !== undefined) {
349 lines.push(...renderDetailedEntry(entry, annotation, annotation.example))
350 } else {
351 lines.push(renderCompactEntry(entry, annotation), "")
352 }
353 }
354 }
355 return `${lines.join("\n").trim()}\n`
356}
357
358export const markdownSafetyIssues = (document: string): ReadonlyArray<string> => {
359 const issues: Array<string> = []
360 let fence: string | undefined
361 const lines = document.split("\n")
362 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
363 const line = lines[lineIndex]!
364 const fenceMatch = /^\s*(`{3,})(?:[^`]*)$/.exec(line)
365 if (fence !== undefined) {
366 if (line.trim() === fence) {
367 fence = undefined
368 }
369 continue
370 }
371 if (fenceMatch !== null) {
372 fence = fenceMatch[1]!
373 continue
374 }
375 if (
376 line === "<!-- dprint-ignore-file -->" ||
377 line.startsWith("**Replacement:** ") ||
378 line === "**Example**"
379 ) {
380 continue
381 }
382 let index = 0
383 while (index < line.length) {
384 const character = line[index]!
385 if (character === "\\") {
386 index += 2
387 continue
388 }
389 if (character === "`") {
390 let end = index + 1
391 while (line[end] === "`") {
392 end++
393 }
394 const delimiter = line.slice(index, end)
395 const close = line.indexOf(delimiter, end)
396 if (close === -1) {
397 issues.push(`line ${lineIndex + 1}: unclosed inline code span`)
398 break
399 }
400 index = close + delimiter.length
401 continue
402 }
403 if (character === "~" || character === "<" || character === "*" || character === "_") {
404 issues.push(`line ${lineIndex + 1}: unescaped ${JSON.stringify(character)}`)
405 }
406 index++
407 }
408 }
409 if (fence !== undefined) {
410 issues.push(`unclosed ${fence} code fence`)
411 }
412 return issues
413}
414
415export const unannotatedApiIds = (
416 diff: ApiDiff,
417 annotations: ReadonlyMap<string, MigrationAnnotation>,
418 importMapSections = ""
419): ReadonlyMap<string, ReadonlyArray<string>> => {
420 const modules = new Map<string, Array<string>>()
421 for (const entry of migrationEntries(diff, annotations, importMapSections)) {
422 if (annotations.has(entry.id)) {
423 continue
424 }
425 const ids = modules.get(entry.module)
426 if (ids === undefined) {
427 modules.set(entry.module, [entry.id])
428 } else {
429 ids.push(entry.id)
430 }
431 }
432 return modules
433}
434
435export const unannotatedModuleIds = (
436 diff: ApiDiff,
437 annotations: ReadonlyMap<string, MigrationAnnotation>,
438 importMapSections: string
439): ReadonlyArray<string> => {
440 const replacements = importMapReplacements(importMapSections)
441 return removedModules(diff).filter((module) =>
442 !annotations.has(module) &&
443 !replacements.has(module) &&
444 ![...annotations.keys()].some((id) => id.startsWith(`${module}#`))
445 )
446}
447
448export const renderMissingAnnotations = (
449 diff: ApiDiff,
450 annotations: ReadonlyMap<string, MigrationAnnotation>,
451 importMapSections = ""
452): string => {
453 const missingApis = unannotatedApiIds(diff, annotations, importMapSections)
454 const missingModules = unannotatedModuleIds(diff, annotations, importMapSections)
455 if (missingApis.size === 0 && missingModules.length === 0) {
456 return "All migration APIs and removed modules have guidance.\n"
457 }
458 return `${
459 [
460 ...(missingModules.length === 0 ? [] : ["Removed modules:", ...missingModules.map((module) => ` - ${module}`)]),
461 ...[...missingApis].flatMap(([module, ids]) => [
462 `${module}:`,
463 ...ids.map((id) => ` - ${id}`)
464 ])
465 ].join("\n")
466 }\n`
467}