Skip to content

Commit a41c559

Browse files
committed
Implement cleanCode, add tests
1 parent 42b342d commit a41c559

File tree

12 files changed

+613
-58
lines changed

12 files changed

+613
-58
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'annotation-comments': minor
3+
---
4+
5+
Adds `cleanCode()` functionality.

packages/annotation-comments/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type AnnotationComment = {
7474
tag: AnnotationTag
7575
contents: string[]
7676
commentRange: SourceRange
77+
commentInnerRange: SourceRange
7778
annotationRange: SourceRange
7879
contentRanges: SourceRange[]
7980
targetRanges: SourceRange[]
@@ -100,9 +101,6 @@ export type SourceLocation = {
100101
101102
### cleanCode()
102103
103-
> **Warning**
104-
> This function is not implemented yet.
105-
106104
This function prepares annotated code lines for display or copying to the clipboard by making it look like regular (non-annotated) code again.
107105
108106
It will collect all necessary edits and apply them to the code in reverse order (from the last edit location to the first) to avoid having to update the locations of all remaining annotations after each edit.
@@ -132,6 +130,7 @@ type HandleEditLineContext = {
132130
lineIndex: number
133131
startColumn: number
134132
endColumn: number
133+
newText?: string | undefined
135134
codeLines: string[]
136135
}
137136
```
Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { AnnotatedCode, AnnotationComment } from './types'
1+
import { cloneRange, createSingleLineRange, excludeRangesFromOuterRange, mergeIntersectingOrAdjacentRanges, rangesAreEqual, splitRangeByLines } from '../internal/ranges'
2+
import { excludeWhitespaceRanges } from '../internal/text-content'
3+
import type { AnnotatedCode, AnnotationComment, SourceLocation, SourceRange } from './types'
24

35
export type CleanCodeOptions = AnnotatedCode & {
46
removeAnnotationContents?: boolean | ((context: RemoveAnnotationContentsContext) => boolean)
@@ -11,19 +13,144 @@ export type RemoveAnnotationContentsContext = {
1113
comment: AnnotationComment
1214
}
1315

14-
export type HandleRemoveLineContext = {
15-
lineIndex: number
16+
export type HandleCodeChangeContextBase = {
1617
codeLines: string[]
1718
}
1819

19-
export type HandleEditLineContext = {
20+
export type HandleRemoveLineContext = HandleCodeChangeContextBase & RemoveLine
21+
export type HandleEditLineContext = HandleCodeChangeContextBase & EditLine
22+
23+
export type RemoveLine = {
24+
editType: 'removeLine'
25+
lineIndex: number
26+
}
27+
28+
export type EditLine = {
29+
editType: 'editLine'
2030
lineIndex: number
2131
startColumn: number
2232
endColumn: number
23-
codeLines: string[]
33+
newText?: string | undefined
2434
}
2535

26-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
36+
type SourceChange = RemoveLine | EditLine
37+
2738
export function cleanCode(options: CleanCodeOptions) {
28-
// TODO: Implement cleanCode()
39+
const { codeLines, annotationComments, removeAnnotationContents = false, updateTargetRanges = true, handleRemoveLine, handleEditLine } = options
40+
41+
// Go through all annotation comments and collect an array of ranges to be removed
42+
const rangesToBeRemoved: SourceRange[] = []
43+
annotationComments.forEach((annotationComment) => {
44+
const hasContents = annotationComment.contents.length > 0
45+
const removeEntireAnnotation =
46+
!hasContents || (typeof removeAnnotationContents === 'function' ? removeAnnotationContents({ comment: annotationComment }) : removeAnnotationContents)
47+
const rangeToBeRemoved = cloneRange(removeEntireAnnotation ? annotationComment.annotationRange : annotationComment.tag.range)
48+
49+
// If we're removing an entire annotation, include up to one line of whitespace above it
50+
const lineAboveIndex = rangeToBeRemoved.start.line - 1
51+
if (removeEntireAnnotation && annotationComment.commentInnerRange.start.line < lineAboveIndex) {
52+
const contentInLineAbove = excludeWhitespaceRanges(codeLines, [createSingleLineRange(lineAboveIndex)])
53+
if (!contentInLineAbove.length) {
54+
rangeToBeRemoved.start = { line: lineAboveIndex }
55+
}
56+
}
57+
58+
rangesToBeRemoved.push(rangeToBeRemoved)
59+
})
60+
61+
// Remove any parent comments that would be empty after removing the annotations
62+
const handledRanges: SourceRange[] = []
63+
annotationComments.forEach(({ commentRange, commentInnerRange }) => {
64+
if (handledRanges.some((range) => rangesAreEqual(range, commentInnerRange))) return
65+
handledRanges.push(commentInnerRange)
66+
// If the outer range is already in the list of ranges to be removed, skip this comment
67+
if (rangesToBeRemoved.some((range) => rangesAreEqual(range, commentRange))) return
68+
const remainingParts = excludeRangesFromOuterRange({
69+
codeLines,
70+
outerRange: commentInnerRange,
71+
rangesToExclude: rangesToBeRemoved,
72+
})
73+
const nonWhitespaceParts = excludeWhitespaceRanges(codeLines, remainingParts)
74+
// If the comment's inner range only contains whitespace after all removals,
75+
// remove the entire comment
76+
if (!nonWhitespaceParts.length) rangesToBeRemoved.push(commentRange)
77+
})
78+
79+
// Build an array of changes by line to be applied to the code
80+
const mergedRangesToBeRemoved = mergeIntersectingOrAdjacentRanges(rangesToBeRemoved)
81+
const changes = mergedRangesToBeRemoved.flatMap((range) => getRangeRemovalChanges(codeLines, range))
82+
83+
// Apply the changes to the code in reverse order to avoid having to change edit locations
84+
changes.reverse()
85+
changes.forEach((change) => {
86+
if (change.editType === 'removeLine') {
87+
if (!handleRemoveLine || !handleRemoveLine({ codeLines, ...change })) {
88+
codeLines.splice(change.lineIndex, 1)
89+
}
90+
if (updateTargetRanges) updateTargetRangesAfterRemoveLine(annotationComments, change)
91+
} else {
92+
if (!handleEditLine || !handleEditLine({ codeLines, ...change })) {
93+
const line = codeLines[change.lineIndex]
94+
codeLines[change.lineIndex] = line.slice(0, change.startColumn) + (change.newText ?? '') + line.slice(change.endColumn)
95+
}
96+
if (updateTargetRanges) updateTargetRangesAfterEditLine(annotationComments, change)
97+
}
98+
})
99+
}
100+
101+
function updateTargetRangesAfterRemoveLine(annotationComments: AnnotationComment[], change: RemoveLine) {
102+
annotationComments.forEach(({ targetRanges }) => {
103+
targetRanges.forEach((targetRange, index) => {
104+
if (targetRange.start.line === change.lineIndex) {
105+
targetRanges.splice(index, 1)
106+
} else if (targetRange.start.line > change.lineIndex) {
107+
targetRange.start.line--
108+
targetRange.end.line--
109+
}
110+
})
111+
})
112+
}
113+
114+
function updateTargetRangesAfterEditLine(annotationComments: AnnotationComment[], change: EditLine) {
115+
annotationComments.forEach(({ targetRanges }) => {
116+
targetRanges.forEach((targetRange) => {
117+
updateLocationIfNecessary(targetRange.start, change)
118+
updateLocationIfNecessary(targetRange.end, change)
119+
})
120+
})
121+
}
122+
123+
function updateLocationIfNecessary(location: SourceLocation, change: EditLine) {
124+
if (location.line !== change.lineIndex) return
125+
if (!location.column || change.startColumn > location.column) return
126+
const changeDelta = (change.newText?.length ?? 0) - (change.endColumn - change.startColumn)
127+
location.column += changeDelta
128+
}
129+
130+
function getRangeRemovalChanges(codeLines: string[], range: SourceRange): SourceChange[] {
131+
const singleLineRanges = splitRangeByLines(range)
132+
133+
return singleLineRanges.map((singleLineRange) => {
134+
const lineIndex = singleLineRange.start.line
135+
const lineLength = codeLines[lineIndex].length
136+
const {
137+
start: { column: startColumn = 0 },
138+
end: { column: endColumn = lineLength },
139+
} = singleLineRange
140+
141+
if (startColumn > 0 || endColumn < lineLength) {
142+
return {
143+
editType: 'editLine',
144+
lineIndex,
145+
startColumn,
146+
endColumn,
147+
newText: '',
148+
}
149+
} else {
150+
return {
151+
editType: 'removeLine',
152+
lineIndex,
153+
}
154+
}
155+
})
29156
}

packages/annotation-comments/src/core/find-targets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,6 @@ export function findAnnotationTargets(annotatedCode: AnnotatedCode) {
9595
}
9696
}
9797
// In case of a negative direction, fix the potentially mixed up order of target ranges
98-
if (relativeTargetRange < 0) targetRanges.sort((a, b) => compareRanges(b, a, 'start'))
98+
if (relativeTargetRange < 0) targetRanges.sort((a, b) => compareRanges(a, b, 'start'))
9999
})
100100
}

packages/annotation-comments/src/core/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export type AnnotationComment = {
99
* In such cases, the comment range is larger than {@link AnnotationComment.annotationRange}.
1010
*/
1111
commentRange: SourceRange
12+
/**
13+
* The inner range of the parent comment that contains the annotation,
14+
* excluding the comment's opening and closing syntax.
15+
*/
16+
commentInnerRange: SourceRange
1217
/**
1318
* The outer range of the annotation, covering both the annotation tag and
1419
* any optional content.

packages/annotation-comments/src/internal/ranges.ts

Lines changed: 132 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export function createRange(options: { codeLines: string[]; start: SourceLocatio
1414
return range
1515
}
1616

17+
/**
18+
* Returns a copy of the given source range.
19+
*/
20+
export function cloneRange(range: SourceRange): SourceRange {
21+
return { start: { ...range.start }, end: { ...range.end } }
22+
}
23+
1724
export function createSingleLineRange(lineIndex: number): SourceRange {
1825
return { start: { line: lineIndex }, end: { line: lineIndex } }
1926
}
@@ -26,35 +33,149 @@ export function createSingleLineRanges(...lineIndices: number[]): SourceRange[]
2633
* Compares two source ranges by their start or end locations.
2734
*
2835
* Returns:
29-
* - `> 0` if the second location is **greater than** (comes after) the first,
30-
* - `< 0` if the second location is **smaller than** (comes before) the first, or
36+
* - `> 0` if the first location is **greater than** (comes after) the second,
37+
* - `< 0` if the first location is **smaller than** (comes before) the second, or
3138
* - `0` if they are equal.
3239
*/
33-
export function compareRanges(a: SourceRange, b: SourceRange, prop: 'start' | 'end'): number {
40+
export function compareRanges(a: SourceRange, b: SourceRange, propA: 'start' | 'end', propB: 'start' | 'end' = propA): number {
3441
// Compare line numbers first
35-
const lineResult = b[prop].line - a[prop].line
42+
const lineResult = a[propA].line - b[propB].line
3643
if (lineResult !== 0) return lineResult
3744

3845
// Line numbers are equal, so compare columns
39-
const aCol = a[prop].column
40-
const bCol = b[prop].column
46+
const aCol = a[propA].column
47+
const bCol = b[propB].column
4148

4249
// If both columns are undefined, the ranges are equal
4350
if (aCol === undefined && bCol === undefined) return 0
4451

4552
// If only one column is undefined (= covers the full line),
4653
// the other column starts after and ends before it
47-
if (aCol === undefined) return prop === 'start' ? 1 : -1
48-
if (bCol === undefined) return prop === 'start' ? -1 : 1
54+
if (aCol === undefined) return propA === 'start' ? -1 : 1
55+
if (bCol === undefined) return propB === 'start' ? 1 : -1
4956

50-
return bCol - aCol
57+
return aCol - bCol
58+
}
59+
60+
export function rangesAreEqual(a: SourceRange, b: SourceRange): boolean {
61+
return compareRanges(a, b, 'start') === 0 && compareRanges(a, b, 'end') === 0
5162
}
5263

5364
export function secondRangeIsInFirst(potentialOuterRange: SourceRange, rangeToTest: SourceRange): boolean {
5465
return (
5566
// To be in range, rangeToTest must start at or after potentialOuterRange...
56-
compareRanges(potentialOuterRange, rangeToTest, 'start') >= 0 &&
67+
compareRanges(rangeToTest, potentialOuterRange, 'start') >= 0 &&
5768
// ...and end at or before potentialOuterRange
58-
compareRanges(potentialOuterRange, rangeToTest, 'end') <= 0
69+
compareRanges(rangeToTest, potentialOuterRange, 'end') <= 0
5970
)
6071
}
72+
73+
/**
74+
* Splits the given range that may span multiple lines into an array of single-line ranges.
75+
*/
76+
export function splitRangeByLines(range: SourceRange): SourceRange[] {
77+
// For single-line ranges, just return a copy of the range
78+
if (range.start.line === range.end.line) return [cloneRange(range)]
79+
80+
// For multi-line ranges, create a mix of column ranges and full line ranges as needed
81+
const ranges: SourceRange[] = []
82+
const isPartialStartLine = range.start.column ? range.start.column > 0 : false
83+
const isPartialEndLine = range.end.column !== undefined
84+
85+
// If the range starts in the middle of a line, add an inline range
86+
if (isPartialStartLine) {
87+
ranges.push({
88+
start: { line: range.start.line, column: range.start.column },
89+
end: { line: range.start.line },
90+
})
91+
}
92+
// Add all full line ranges
93+
for (let lineIndex = range.start.line + (isPartialStartLine ? 1 : 0); lineIndex < range.end.line + (isPartialEndLine ? 0 : 1); lineIndex++) {
94+
ranges.push(createSingleLineRange(lineIndex))
95+
}
96+
// If the range ends in the middle of a line, add an inline range
97+
if (isPartialEndLine) {
98+
ranges.push({
99+
start: { line: range.end.line },
100+
end: { line: range.end.line, column: range.end.column },
101+
})
102+
}
103+
return ranges
104+
}
105+
106+
/**
107+
* Merges any intersecting or adjacent ranges in the given array of source ranges.
108+
*/
109+
export function mergeIntersectingOrAdjacentRanges(ranges: SourceRange[]): SourceRange[] {
110+
const sortedRanges = ranges.slice().sort((a, b) => compareRanges(a, b, 'start'))
111+
const mergedRanges: SourceRange[] = []
112+
let currentRange: SourceRange | undefined
113+
for (const newRange of sortedRanges) {
114+
if (!currentRange) {
115+
currentRange = cloneRange(newRange)
116+
continue
117+
}
118+
// If the new range starts inside or right at the end of the current one,
119+
// extend the current range if needed
120+
if (compareRanges(newRange, currentRange, 'start', 'end') <= 0) {
121+
if (compareRanges(newRange, currentRange, 'end') > 0) currentRange.end = newRange.end
122+
continue
123+
}
124+
// Otherwise, we're done with the current range and can switch to the new one
125+
mergedRanges.push(currentRange)
126+
currentRange = cloneRange(newRange)
127+
}
128+
if (currentRange) mergedRanges.push(currentRange)
129+
return mergedRanges
130+
}
131+
132+
/**
133+
* Excludes the given exclusion ranges from the outer range by splitting the outer range into
134+
* multiple parts that do not overlap with the exclusions.
135+
*
136+
* The resulting array of source ranges is split by lines and can be a combination of partial
137+
* and full line ranges. The array can also be empty if the outer range is completely covered
138+
* by the exclusions.
139+
*/
140+
export function excludeRangesFromOuterRange(options: { codeLines: string[]; outerRange: SourceRange; rangesToExclude: SourceRange[] }): SourceRange[] {
141+
const { codeLines, outerRange, rangesToExclude } = options
142+
143+
const remainingRanges: SourceRange[] = splitRangeByLines(outerRange)
144+
const exclusionsSplitByLine = rangesToExclude.flatMap((exclusion) => splitRangeByLines(exclusion))
145+
146+
exclusionsSplitByLine.forEach((exclusion) => {
147+
const lineIndex = exclusion.start.line
148+
const lineLength = codeLines[lineIndex].length
149+
const exclusionStartColumn = exclusion.start.column ?? 0
150+
const exclusionEndColumn = exclusion.end.column ?? lineLength
151+
for (let i = remainingRanges.length - 1; i >= 0; i--) {
152+
const range = remainingRanges[i]
153+
// If the range is on a different line, it cannot be affected by the exclusion
154+
if (range.start.line !== lineIndex) continue
155+
const rangeStartColumn = range.start.column ?? 0
156+
const rangeEndColumn = range.end.column ?? lineLength
157+
if (exclusionStartColumn <= rangeStartColumn && exclusionEndColumn >= rangeEndColumn) {
158+
// The exclusion completely covers the range, so remove it
159+
remainingRanges.splice(i, 1)
160+
} else if (exclusionStartColumn <= rangeStartColumn && exclusionEndColumn < rangeEndColumn) {
161+
// The exclusion overlaps with the start of the range, so adjust the range start
162+
range.start.column = exclusionEndColumn
163+
} else if (exclusionStartColumn > rangeStartColumn && exclusionEndColumn >= rangeEndColumn) {
164+
// The exclusion overlaps with the end of the range, so adjust the range end
165+
range.end.column = exclusionStartColumn
166+
} else if (exclusionStartColumn > rangeStartColumn && exclusionEndColumn < rangeEndColumn) {
167+
// The exclusion is inside the range, so split the range into two
168+
// ...by making the current range end before the exclusion
169+
range.end.column = exclusionStartColumn
170+
// ...and inserting a new range that starts after the exclusion
171+
// and ends at the end of the current range
172+
const rangeAfterExclusion = createSingleLineRange(lineIndex)
173+
if (exclusionEndColumn > 0) rangeAfterExclusion.start.column = exclusionEndColumn
174+
if (rangeEndColumn < lineLength) rangeAfterExclusion.end.column = rangeEndColumn
175+
remainingRanges.splice(i + 1, 0, rangeAfterExclusion)
176+
}
177+
}
178+
})
179+
180+
return remainingRanges
181+
}

0 commit comments

Comments
 (0)