mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 17:03:32 +03:00
874e9efe74
Fixes probably #9198 See [this comment](https://github.com/enso-org/enso/issues/9198#issuecomment-1968484269) for justification. TLDR: the diff algorithm is too slow for our huge idmap. The proper fix would be to reduce idmap size. Expect tasks for that soon.
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* @file A module responsible for translating file edits between the Yjs document updates and the
|
|
* Language server protocol structures.
|
|
*/
|
|
|
|
import diff from 'fast-diff'
|
|
import type { ModuleUpdate } from '../shared/ast'
|
|
import { MutableModule, print, spanMapToIdMap } from '../shared/ast'
|
|
import { EnsoFileParts } from '../shared/ensoFile'
|
|
import { TextEdit } from '../shared/languageServerTypes'
|
|
import { assert } from '../shared/util/assert'
|
|
import { IdMap, ModuleDoc, type VisualizationMetadata } from '../shared/yjsModel'
|
|
import * as fileFormat from './fileFormat'
|
|
|
|
/**
|
|
* The simulated metadata of this size takes c.a. 1 second on my machine. It should be quite
|
|
* bearable, even on slower machines.
|
|
*
|
|
* Full benchmark results (from edits.bench.ts):
|
|
* name hz min max mean p75 p99 p995 p999 rme samples
|
|
* · Diffing 10000 8.7370 108.66 132.93 114.46 111.73 132.93 132.93 132.93 ±11.28% 5
|
|
* · Diffing 15000 4.0483 239.82 257.99 247.02 257.99 257.99 257.99 257.99 ±9.71% 3
|
|
* · Diffing 20000 2.1577 462.40 464.52 463.46 464.52 464.52 464.52 464.52 ±2.90% 2
|
|
* · Diffing 25000 1.3744 727.61 727.61 727.61 727.61 727.61 727.61 727.61 ±0.00% 1
|
|
* · Diffing 30000 0.9850 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 ±0.00% 1
|
|
* · Diffing 35000 0.6934 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 ±0.00% 1
|
|
* · Diffing 40000 0.5141 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 ±0.00% 1
|
|
* · Diffing 50000 0.3315 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 ±0.00% 1
|
|
* · Diffing 60000 0.2270 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 ±0.00% 1
|
|
* · Diffing 70000 0.1602 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 ±0.00% 1
|
|
* · Diffing 80000 0.1233 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 ±0.00% 1
|
|
* · Diffing 90000 0.0954 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 ±0.00% 1
|
|
* · Diffing 100000 0.0788 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 ±0.00% 1
|
|
* · Diffing 250000 0.0107 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 ±0.00% 1
|
|
*/
|
|
const MAX_SIZE_FOR_NORMAL_DIFF = 30000
|
|
|
|
interface AppliedUpdates {
|
|
newCode: string | undefined
|
|
newIdMap: IdMap | undefined
|
|
newMetadata: fileFormat.IdeMetadata['node'] | undefined
|
|
}
|
|
|
|
export function applyDocumentUpdates(
|
|
doc: ModuleDoc,
|
|
synced: EnsoFileParts,
|
|
update: ModuleUpdate,
|
|
): AppliedUpdates {
|
|
const codeChanged = update.nodesUpdated.size !== 0
|
|
let idsChanged = false
|
|
let metadataChanged = false
|
|
for (const { changes } of update.metadataUpdated) {
|
|
for (const [key] of changes) {
|
|
if (key === 'externalId') {
|
|
idsChanged = true
|
|
} else {
|
|
metadataChanged = true
|
|
}
|
|
}
|
|
if (idsChanged && metadataChanged) break
|
|
}
|
|
|
|
let newIdMap = undefined
|
|
let newCode = undefined
|
|
let newMetadata = undefined
|
|
|
|
const syncModule = new MutableModule(doc.ydoc)
|
|
const root = syncModule.root()
|
|
assert(root != null)
|
|
if (codeChanged || idsChanged || synced.idMapJson == null) {
|
|
const { code, info } = print(root)
|
|
if (codeChanged) newCode = code
|
|
newIdMap = spanMapToIdMap(info)
|
|
}
|
|
if (codeChanged || idsChanged || metadataChanged) {
|
|
// Update the metadata object.
|
|
// Depth-first key order keeps diffs small.
|
|
newMetadata = {} satisfies fileFormat.IdeMetadata['node']
|
|
root.visitRecursiveAst((ast) => {
|
|
let pos = ast.nodeMetadata.get('position')
|
|
const vis = ast.nodeMetadata.get('visualization')
|
|
if (vis && !pos) pos = { x: 0, y: 0 }
|
|
if (pos) {
|
|
newMetadata![ast.externalId] = {
|
|
position: { vector: [Math.round(pos.x), Math.round(-pos.y)] },
|
|
visualization: vis && translateVisualizationToFile(vis),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
return { newCode, newIdMap, newMetadata }
|
|
}
|
|
|
|
function translateVisualizationToFile(
|
|
vis: VisualizationMetadata,
|
|
): fileFormat.VisualizationMetadata | undefined {
|
|
let project = undefined
|
|
switch (vis.identifier?.module.kind) {
|
|
case 'Builtin':
|
|
project = { project: 'Builtin' } as const
|
|
break
|
|
case 'CurrentProject':
|
|
project = { project: 'CurrentProject' } as const
|
|
break
|
|
case 'Library':
|
|
project = { project: 'Library', contents: vis.identifier.module.name } as const
|
|
break
|
|
default:
|
|
return { show: vis.visible }
|
|
}
|
|
return {
|
|
name: vis.identifier.name,
|
|
show: vis.visible,
|
|
project,
|
|
}
|
|
}
|
|
|
|
export function translateVisualizationFromFile(
|
|
vis: fileFormat.VisualizationMetadata,
|
|
): VisualizationMetadata | undefined {
|
|
let module
|
|
switch (vis.project?.project) {
|
|
case 'Builtin':
|
|
module = { kind: 'Builtin' } as const
|
|
break
|
|
case 'CurrentProject':
|
|
module = { kind: 'CurrentProject' } as const
|
|
break
|
|
case 'Library':
|
|
module = { kind: 'Library', name: vis.project.contents } as const
|
|
break
|
|
default:
|
|
module = null
|
|
}
|
|
return {
|
|
identifier: module && vis.name ? { name: vis.name, module } : null,
|
|
visible: vis.show,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A simplified diff algorithm.
|
|
*
|
|
* The `fast-diff` package uses Myers' https://neil.fraser.name/writing/diff/myers.pdf with some
|
|
* optimizations to generate minimal diff. Unfortunately, event this algorithm is still to slow
|
|
* for our metadata. Therefore we need to use faster algorithm which will not produce theoretically
|
|
* minimal diff.
|
|
*
|
|
* This is quick implementation making diff which just replaces entire string except common prefix
|
|
* and suffix.
|
|
*/
|
|
export function stupidFastDiff(oldString: string, newString: string): diff.Diff[] {
|
|
const minLength = Math.min(oldString.length, newString.length)
|
|
let commonPrefixLen, commonSuffixLen
|
|
for (commonPrefixLen = 0; commonPrefixLen < minLength; ++commonPrefixLen)
|
|
if (oldString[commonPrefixLen] !== newString[commonPrefixLen]) break
|
|
if (oldString.length === newString.length && oldString.length === commonPrefixLen)
|
|
return [[0, oldString]]
|
|
for (commonSuffixLen = 0; commonSuffixLen < minLength - commonPrefixLen; ++commonSuffixLen)
|
|
if (oldString.at(-1 - commonSuffixLen) !== newString.at(-1 - commonSuffixLen)) break
|
|
const commonPrefix = oldString.substring(0, commonPrefixLen)
|
|
const removed = oldString.substring(commonPrefixLen, oldString.length - commonSuffixLen)
|
|
const added = newString.substring(commonPrefixLen, newString.length - commonSuffixLen)
|
|
const commonSuffix = oldString.substring(oldString.length - commonSuffixLen, oldString.length)
|
|
return (commonPrefix ? ([[0, commonPrefix]] as diff.Diff[]) : [])
|
|
.concat(removed ? [[-1, removed]] : [])
|
|
.concat(added ? [[1, added]] : [])
|
|
.concat(commonSuffix ? [[0, commonSuffix]] : [])
|
|
}
|
|
|
|
export function applyDiffAsTextEdits(
|
|
lineOffset: number,
|
|
oldString: string,
|
|
newString: string,
|
|
): TextEdit[] {
|
|
const changes =
|
|
oldString.length + newString.length > MAX_SIZE_FOR_NORMAL_DIFF
|
|
? stupidFastDiff(oldString, newString)
|
|
: diff(oldString, newString)
|
|
let newIndex = 0
|
|
let lineNum = lineOffset
|
|
let lineStartIdx = 0
|
|
const edits = []
|
|
for (const [op, text] of changes) {
|
|
if (op === 1) {
|
|
const pos = {
|
|
character: newIndex - lineStartIdx,
|
|
line: lineNum,
|
|
}
|
|
edits.push({ range: { start: pos, end: pos }, text })
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
if (numLineBreaks > 0) {
|
|
lineNum += numLineBreaks
|
|
lineStartIdx = newIndex + text.lastIndexOf('\n') + 1
|
|
}
|
|
newIndex += text.length
|
|
} else if (op === -1) {
|
|
const start = {
|
|
character: newIndex - lineStartIdx,
|
|
line: lineNum,
|
|
}
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
const character =
|
|
numLineBreaks > 0
|
|
? text.length - (text.lastIndexOf('\n') + 1)
|
|
: newIndex - lineStartIdx + text.length
|
|
const end = {
|
|
character,
|
|
line: lineNum + numLineBreaks,
|
|
}
|
|
edits.push({ range: { start, end }, text: '' })
|
|
} else if (op === 0) {
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
lineNum += numLineBreaks
|
|
if (numLineBreaks > 0) {
|
|
lineStartIdx = newIndex + text.lastIndexOf('\n') + 1
|
|
}
|
|
newIndex += text.length
|
|
}
|
|
}
|
|
return edits
|
|
}
|
|
|
|
export function prettyPrintDiff(from: string, to: string): string {
|
|
const colReset = '\x1b[0m'
|
|
const colRed = '\x1b[31m'
|
|
const colGreen = '\x1b[32m'
|
|
|
|
const diffs =
|
|
from.length + to.length > MAX_SIZE_FOR_NORMAL_DIFF ? stupidFastDiff(from, to) : diff(from, to)
|
|
if (diffs.length === 1 && diffs[0]![0] === 0) return 'No changes'
|
|
let content = ''
|
|
for (let i = 0; i < diffs.length; i++) {
|
|
const [op, text] = diffs[i]!
|
|
if (op === 1) {
|
|
content += colGreen + text
|
|
} else if (op === -1) {
|
|
content += colRed + text
|
|
} else if (op === 0) {
|
|
content += colReset
|
|
const numNewlines = (text.match(/\n/g) ?? []).length
|
|
if (numNewlines < 2) {
|
|
content += text
|
|
} else {
|
|
const firstNewline = text.indexOf('\n')
|
|
const lastNewline = text.lastIndexOf('\n')
|
|
const firstLine = text.slice(0, firstNewline + 1)
|
|
const lastLine = text.slice(lastNewline + 1)
|
|
const isFirst = i === 0
|
|
const isLast = i === diffs.length - 1
|
|
if (!isFirst) content += firstLine
|
|
if (!isFirst && !isLast) content += '...\n'
|
|
if (!isLast) content += lastLine
|
|
}
|
|
}
|
|
}
|
|
content += colReset
|
|
return content
|
|
}
|
|
|
|
if (import.meta.vitest) {
|
|
const { test, expect, bench, describe } = import.meta.vitest
|
|
|
|
test.each`
|
|
oldStr | newStr | expected
|
|
${''} | ${'foo'} | ${[[1, 'foo']]}
|
|
${'foo'} | ${''} | ${[[-1, 'foo']]}
|
|
${'foo'} | ${'foo'} | ${[[0, 'foo']]}
|
|
${'foo'} | ${'bar'} | ${[[-1, 'foo'], [1, 'bar']]}
|
|
${'ababx'} | ${'acacx'} | ${[[0, 'a'], [-1, 'bab'], [1, 'cac'], [0, 'x']]}
|
|
${'ax'} | ${'acacx'} | ${[[0, 'a'], [1, 'cac'], [0, 'x']]}
|
|
${'ababx'} | ${'ax'} | ${[[0, 'a'], [-1, 'bab'], [0, 'x']]}
|
|
${'ababx'} | ${'abacax'} | ${[[0, 'aba'], [-1, 'b'], [1, 'ca'], [0, 'x']]}
|
|
${'axxxa'} | ${'a'} | ${[[0, 'a'], [-1, 'xxxa']]}
|
|
`('Stupid diff of $oldStr and $newStr', ({ oldStr, newStr, expected }) => {
|
|
expect(stupidFastDiff(oldStr, newStr)).toEqual(expected)
|
|
})
|
|
}
|