mirror of
https://github.com/enso-org/enso.git
synced 2024-12-21 12:02:27 +03:00
ad0c1bc188
Fixes #7967 The text updates should no longer be rejected after applying edits containing newlines. The tricky update translation logic was also covered with property-based unit tests. https://github.com/enso-org/enso/assets/919491/0bfb6181-7244-4eff-8d72-5b1a4630b9a6
176 lines
5.4 KiB
TypeScript
176 lines
5.4 KiB
TypeScript
import { fc, test } from '@fast-check/vitest'
|
|
import { Position, TextEdit } from 'shared/languageServerTypes'
|
|
import { inspect } from 'util'
|
|
import { describe, expect } from 'vitest'
|
|
import * as Y from 'yjs'
|
|
import { applyDiffAsTextEdits, convertDeltaToTextEdits } from '../edits'
|
|
|
|
// ======================
|
|
// === Test utilities ===
|
|
// ======================
|
|
|
|
/** Apply text edits intended for language server to a given starting text. Used for verification
|
|
* during testing if generated edits were correct. This is intentionally a very simple, not
|
|
* performant implementation.
|
|
*/
|
|
export function applyTextEdits(content: string, edits: TextEdit[]): string {
|
|
return edits.reduce((c, edit) => {
|
|
try {
|
|
const startOffset = lsPositionToTextOffset(c, edit.range.start)
|
|
const endOffset = lsPositionToTextOffset(c, edit.range.end)
|
|
return c.slice(0, startOffset) + edit.text + c.slice(endOffset)
|
|
} catch (e) {
|
|
throw new Error(
|
|
`Failed to apply edit ${inspect(edit)} to content:\n${inspect(c)}\n${String(e)}`,
|
|
)
|
|
}
|
|
}, content)
|
|
}
|
|
|
|
function lsPositionToTextOffset(content: string, pos: Position): number {
|
|
const lineData = getNthLineLengthAndOffset(content, pos.line)
|
|
if (pos.character > lineData.length)
|
|
throw new Error(
|
|
`Character ${pos.character} is out of bounds for line ${pos.line}. ` +
|
|
`Line length ${lineData.length}.`,
|
|
)
|
|
return lineData.offset + pos.character
|
|
}
|
|
|
|
function getNthLineLengthAndOffset(content: string, line: number) {
|
|
const nthLineRegex = new RegExp(`((?:.*\\n){${line}})(.*)(?:\n|$)`)
|
|
const match = nthLineRegex.exec(content)
|
|
if (!match) throw new Error(`Line ${line} not found in content:\n${content}`)
|
|
return {
|
|
offset: match[1].length,
|
|
length: match[2].length,
|
|
}
|
|
}
|
|
|
|
// ==================
|
|
// === Test suite ===
|
|
// ==================
|
|
|
|
describe('applyDiffAsTextEdits', () => {
|
|
test('no change', () => {
|
|
const edits = applyDiffAsTextEdits(0, 'abcd', 'abcd')
|
|
expect(edits).toStrictEqual([])
|
|
})
|
|
test('simple add', () => {
|
|
const before = 'abcd'
|
|
const after = 'abefcd'
|
|
const edits = applyDiffAsTextEdits(1, before, after)
|
|
expect(edits).toStrictEqual([
|
|
{
|
|
range: { end: { character: 2, line: 1 }, start: { character: 2, line: 1 } },
|
|
text: 'ef',
|
|
},
|
|
])
|
|
})
|
|
|
|
test('two adds', () => {
|
|
const before = 'abcd'
|
|
const after = 'abefcdxy'
|
|
const edits = applyDiffAsTextEdits(1, before, after)
|
|
expect(edits).toStrictEqual([
|
|
{
|
|
range: { end: { character: 2, line: 1 }, start: { character: 2, line: 1 } },
|
|
text: 'ef',
|
|
},
|
|
{
|
|
range: { end: { character: 6, line: 1 }, start: { character: 6, line: 1 } },
|
|
text: 'xy',
|
|
},
|
|
])
|
|
})
|
|
|
|
test.prop({
|
|
linesBefore: fc.array(fc.string(), { minLength: 1 }),
|
|
linesAfter: fc.array(fc.string(), { minLength: 1 }),
|
|
ctx: fc.context(),
|
|
})('should correctly create edits from random diffs', ({ linesBefore, linesAfter, ctx }) => {
|
|
const before = linesBefore.join('\n')
|
|
const after = linesAfter.join('\n')
|
|
const edits = applyDiffAsTextEdits(0, before, after)
|
|
for (const edit of edits) {
|
|
ctx.log(
|
|
`${edit.range.start.line}:${edit.range.start.character} - ` +
|
|
`${edit.range.end.line}:${edit.range.end.character} - '${edit.text}'`,
|
|
)
|
|
}
|
|
const applied = applyTextEdits(before, edits)
|
|
expect(applied).toBe(after)
|
|
})
|
|
})
|
|
|
|
describe('convertDeltaToTextEdits', () => {
|
|
function genDeltaAndResultFromOps(
|
|
initial: string,
|
|
ops: ({ insert: [number, string] } | { delete: [number, number] })[],
|
|
): { delta: Y.YTextEvent['delta']; result: string } {
|
|
const doc = new Y.Doc()
|
|
const ytext = doc.getText()
|
|
ytext.insert(0, initial)
|
|
let delta: Y.YTextEvent['delta'] | null = null
|
|
ytext.observe((e) => {
|
|
delta = e.delta
|
|
})
|
|
|
|
doc.transact(() => {
|
|
for (const op of ops) {
|
|
if ('insert' in op) {
|
|
ytext.insert(Math.min(op.insert[0], ytext.length), op.insert[1])
|
|
} else {
|
|
const idx = Math.min(op.delete[0], ytext.length)
|
|
const len = Math.min(op.delete[1], ytext.length - idx)
|
|
ytext.delete(idx, len)
|
|
}
|
|
}
|
|
})
|
|
|
|
return {
|
|
delta: delta ?? [],
|
|
result: ytext.toString(),
|
|
}
|
|
}
|
|
|
|
function deltaOp() {
|
|
return fc.oneof(
|
|
fc.record({ insert: fc.tuple(fc.nat(), fc.string()) }),
|
|
fc.record({ delete: fc.tuple(fc.nat(), fc.nat()) }),
|
|
)
|
|
}
|
|
|
|
test.prop({
|
|
initialLines: fc.array(fc.string()),
|
|
ops: fc.array(deltaOp()),
|
|
deltaCtx: fc.context(),
|
|
ctx: fc.context(),
|
|
})(
|
|
'should correctly translate deltas to random diffs',
|
|
({ initialLines, ops, deltaCtx, ctx }) => {
|
|
const initial = initialLines.join('\n')
|
|
const { delta, result } = genDeltaAndResultFromOps(initial, ops)
|
|
for (const op of delta) {
|
|
if ('retain' in op) {
|
|
deltaCtx.log(`retain ${op.retain}`)
|
|
} else if ('delete' in op) {
|
|
deltaCtx.log(`delete ${op.delete}`)
|
|
} else {
|
|
deltaCtx.log(`insert ${op.insert}`)
|
|
}
|
|
}
|
|
const { code, edits } = convertDeltaToTextEdits(initial, delta)
|
|
for (const edit of edits) {
|
|
ctx.log(
|
|
`${edit.range.start.line}:${edit.range.start.character} - ` +
|
|
`${edit.range.end.line}:${edit.range.end.character} - '${edit.text}'`,
|
|
)
|
|
}
|
|
const applied = applyTextEdits(initial, edits)
|
|
expect(applied).toBe(result)
|
|
expect(code).toBe(result)
|
|
},
|
|
)
|
|
})
|