From 90c7bfe679e37c128729089cd63491c399f38ebb Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Fri, 6 Dec 2024 18:03:36 -0500 Subject: [PATCH] Add support for Delta documents --- package-lock.json | 57 ++++++++- packages/core/src/common/index.ts | 1 - packages/core/src/common/text-edit.ts | 6 - .../core/src/diagnostic/diagnostic-fix.ts | 3 +- .../core/src/document/document-factory.ts | 12 +- .../core/src/document/document-manager.ts | 6 +- packages/core/src/document/document.ts | 4 + packages/core/src/document/index.ts | 5 +- .../core/src/document/scripture-document.ts | 17 ++- .../core/src/document/text-document-change.ts | 6 + .../core/src/document/text-document-edit.ts | 6 + .../src/document/text-document-factory.ts | 9 +- packages/core/src/document/text-document.ts | 28 ++-- .../src/document/text-scripture-document.ts | 57 +++++++++ .../formatting/on-type-formatting-provider.ts | 3 +- packages/core/src/workspace/workspace.ts | 3 +- packages/delta/eslint.config.js | 12 ++ packages/delta/package.json | 47 +++++++ packages/delta/src/delta-document-factory.ts | 21 +++ packages/delta/src/delta-document.test.ts | 34 +++++ packages/delta/src/delta-document.ts | 121 ++++++++++++++++++ packages/delta/tsconfig.json | 8 ++ packages/delta/tsup.config.js | 4 + .../src/simple-quote-formatting-provider.ts | 14 +- .../verse-order-diagnostic-provider.test.ts | 80 ++++++------ .../src/verse-order-diagnostic-provider.ts | 16 +-- packages/usfm/src/usfm-document-factory.ts | 14 +- packages/usfm/src/usfm-document.test.ts | 19 ++- packages/usfm/src/usfm-document.ts | 23 +++- .../src/usfm-scripture-serializer.test.ts | 2 +- packages/vscode/src/server.ts | 10 +- 31 files changed, 516 insertions(+), 132 deletions(-) delete mode 100644 packages/core/src/common/text-edit.ts create mode 100644 packages/core/src/document/text-document-change.ts create mode 100644 packages/core/src/document/text-document-edit.ts create mode 100644 packages/core/src/document/text-scripture-document.ts create mode 100644 packages/delta/eslint.config.js create mode 100644 packages/delta/package.json create mode 100644 packages/delta/src/delta-document-factory.ts create mode 100644 packages/delta/src/delta-document.test.ts create mode 100644 packages/delta/src/delta-document.ts create mode 100644 packages/delta/tsconfig.json create mode 100644 packages/delta/tsup.config.js diff --git a/package-lock.json b/package-lock.json index 4e93bf3..a350972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1535,6 +1535,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@sillsdev/lynx-delta": { + "resolved": "packages/delta", + "link": true + }, "node_modules/@sillsdev/lynx-examples": { "resolved": "packages/examples", "link": true @@ -3415,6 +3419,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4617,6 +4627,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5316,6 +5338,20 @@ ], "license": "MIT" }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -7509,7 +7545,7 @@ }, "packages/core": { "name": "@sillsdev/lynx", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "i18next": "^23.16.5", @@ -7525,6 +7561,23 @@ "vitest-mock-extended": "^2.0.2" } }, + "packages/delta": { + "name": "@sillsdev/lynx-delta", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "*", + "quill-delta": "^5.1.0" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/tsup-config": "*", + "@repo/typescript-config": "*", + "eslint": "^9.9.1", + "tsup": "^8.3.0", + "typescript": "^5.5.4" + } + }, "packages/eslint-config": { "name": "@repo/eslint-config", "version": "0.0.0", @@ -7569,7 +7622,7 @@ }, "packages/usfm": { "name": "@sillsdev/lynx-usfm", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "@sillsdev/lynx": "*", diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 40a8145..f1c63e3 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -1,3 +1,2 @@ export type { Position } from './position'; export type { Range } from './range'; -export type { TextEdit } from './text-edit'; diff --git a/packages/core/src/common/text-edit.ts b/packages/core/src/common/text-edit.ts deleted file mode 100644 index 4415f76..0000000 --- a/packages/core/src/common/text-edit.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Range } from './range'; - -export interface TextEdit { - range: Range; - newText: string; -} diff --git a/packages/core/src/diagnostic/diagnostic-fix.ts b/packages/core/src/diagnostic/diagnostic-fix.ts index a6cfb6a..9daf1a7 100644 --- a/packages/core/src/diagnostic/diagnostic-fix.ts +++ b/packages/core/src/diagnostic/diagnostic-fix.ts @@ -1,9 +1,8 @@ -import { TextEdit } from '../common/text-edit'; import { Diagnostic } from './diagnostic'; export interface DiagnosticFix { title: string; diagnostic: Diagnostic; isPreferred?: boolean; - edits: TextEdit[]; + edits: unknown[]; } diff --git a/packages/core/src/document/document-factory.ts b/packages/core/src/document/document-factory.ts index 5b97704..7c25775 100644 --- a/packages/core/src/document/document-factory.ts +++ b/packages/core/src/document/document-factory.ts @@ -1,12 +1,6 @@ -import { Range } from '../common/range'; import { Document } from './document'; -export interface DocumentChange { - range?: Range; - text: string; -} - -export interface DocumentFactory { - create(uri: string, format: string, version: number, content: string): T; - update(document: T, changes: readonly DocumentChange[], version: number): T; +export interface DocumentFactory { + create(uri: string, format: string, version: number, content: unknown): T; + update(document: T, changes: readonly unknown[], version: number): T; } diff --git a/packages/core/src/document/document-manager.ts b/packages/core/src/document/document-manager.ts index 4f9a5ab..2be5cc6 100644 --- a/packages/core/src/document/document-manager.ts +++ b/packages/core/src/document/document-manager.ts @@ -1,7 +1,7 @@ import { Observable, Subject } from 'rxjs'; import { Document } from './document'; -import { DocumentChange, DocumentFactory } from './document-factory'; +import { DocumentFactory } from './document-factory'; import { DocumentReader } from './document-reader'; export interface DocumentCreated { @@ -24,7 +24,7 @@ export interface DocumentChanged { document: T; } -export class DocumentManager { +export class DocumentManager { private readonly documents = new Map(); private readonly activeDocuments = new Set(); private readonly createdSubject = new Subject>(); @@ -124,7 +124,7 @@ export class DocumentManager { return Promise.resolve(); } - async fireChanged(uri: string, changes?: readonly DocumentChange[], version?: number): Promise { + async fireChanged(uri: string, changes?: readonly unknown[], version?: number): Promise { let doc: T | undefined = undefined; if (changes == null) { doc = await this.reload(uri); diff --git a/packages/core/src/document/document.ts b/packages/core/src/document/document.ts index 70509bb..21c7fc8 100644 --- a/packages/core/src/document/document.ts +++ b/packages/core/src/document/document.ts @@ -1,3 +1,7 @@ export interface Document { readonly uri: string; + readonly version: number; + + getText(): string; + createTextEdit(startOffset: number, endOffset: number, newText: string): unknown[]; } diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts index bc14b54..9cdcfb8 100644 --- a/packages/core/src/document/index.ts +++ b/packages/core/src/document/index.ts @@ -1,5 +1,5 @@ export type { Document } from './document'; -export type { DocumentChange, DocumentFactory } from './document-factory'; +export type { DocumentFactory } from './document-factory'; export { DocumentManager } from './document-manager'; export type { DocumentReader } from './document-reader'; export { ScriptureBook } from './scripture-book'; @@ -22,4 +22,7 @@ export { ScriptureTable } from './scripture-table'; export { ScriptureText } from './scripture-text'; export { ScriptureVerse } from './scripture-verse'; export { TextDocument } from './text-document'; +export type { TextDocumentChange } from './text-document-change'; +export type { TextDocumentEdit } from './text-document-edit'; export { TextDocumentFactory } from './text-document-factory'; +export { TextScriptureDocument } from './text-scripture-document'; diff --git a/packages/core/src/document/scripture-document.ts b/packages/core/src/document/scripture-document.ts index 1496791..ae9a1a3 100644 --- a/packages/core/src/document/scripture-document.ts +++ b/packages/core/src/document/scripture-document.ts @@ -1,23 +1,20 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; import { Document } from './document'; -import { TextDocument } from './text-document'; -export class ScriptureDocument extends TextDocument implements Document, ScriptureNode { +export abstract class ScriptureDocument implements Document, ScriptureNode { private readonly _children: ScriptureNode[] = []; readonly parent: undefined = undefined; readonly isLeaf = false; readonly type = ScriptureNodeType.Document; readonly document = this; + abstract readonly version: number; range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; constructor( public readonly uri: string, - version: number, - content: string, children?: ScriptureNode[], ) { - super(uri, version, content); if (children != null) { for (const child of children) { this.appendChild(child); @@ -78,6 +75,16 @@ export class ScriptureDocument extends TextDocument implements Document, Scriptu clearChildren(): void { this._children.length = 0; } + + abstract getText(range?: Range): string; + abstract offsetAt(position: Position): number; + abstract positionAt(offset: number, range?: Range): Position; + abstract createTextEdit(startOffset: number, endOffset: number, newText: string): unknown[]; + abstract createScriptureEdit( + startOffset: number, + endOffset: number, + nodes: ScriptureNode[] | ScriptureNode, + ): unknown[]; } export enum ScriptureNodeType { diff --git a/packages/core/src/document/text-document-change.ts b/packages/core/src/document/text-document-change.ts new file mode 100644 index 0000000..b2af345 --- /dev/null +++ b/packages/core/src/document/text-document-change.ts @@ -0,0 +1,6 @@ +import { Range } from '../common/range'; + +export interface TextDocumentChange { + range?: Range; + text: string; +} diff --git a/packages/core/src/document/text-document-edit.ts b/packages/core/src/document/text-document-edit.ts new file mode 100644 index 0000000..9023e23 --- /dev/null +++ b/packages/core/src/document/text-document-edit.ts @@ -0,0 +1,6 @@ +import { Range } from '../common/range'; + +export interface TextDocumentEdit { + range: Range; + newText: string; +} diff --git a/packages/core/src/document/text-document-factory.ts b/packages/core/src/document/text-document-factory.ts index cf46fc8..4b91f7f 100644 --- a/packages/core/src/document/text-document-factory.ts +++ b/packages/core/src/document/text-document-factory.ts @@ -1,12 +1,13 @@ -import { DocumentChange, DocumentFactory } from './document-factory'; +import { DocumentFactory } from './document-factory'; import { TextDocument } from './text-document'; +import { TextDocumentChange } from './text-document-change'; export class TextDocumentFactory implements DocumentFactory { - create(uri: string, _format: string, version: number, content: string): TextDocument { - return new TextDocument(uri, version, content); + create(uri: string, format: string, version: number, content: string): TextDocument { + return new TextDocument(uri, format, version, content); } - update(document: TextDocument, changes: readonly DocumentChange[], version: number): TextDocument { + update(document: TextDocument, changes: readonly TextDocumentChange[], version: number): TextDocument { document.update(changes, version); return document; } diff --git a/packages/core/src/document/text-document.ts b/packages/core/src/document/text-document.ts index d1269c5..e7442a7 100644 --- a/packages/core/src/document/text-document.ts +++ b/packages/core/src/document/text-document.ts @@ -1,19 +1,19 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; import { Document } from './document'; -import { DocumentChange } from './document-factory'; +import { TextDocumentChange } from './text-document-change'; +import { TextDocumentEdit } from './text-document-edit'; export class TextDocument implements Document { private _lineOffsets: number[] | undefined = undefined; private _content: string; - private _version: number; constructor( public readonly uri: string, - version: number, + public readonly format: string, + public version: number, content: string, ) { - this._version = version; this._content = content; } @@ -21,14 +21,6 @@ export class TextDocument implements Document { return this._content; } - get version(): number { - return this._version; - } - - protected set version(value: number) { - this._version = value; - } - getText(range?: Range): string { if (range != null) { const start = this.offsetAt(range.start); @@ -95,14 +87,14 @@ export class TextDocument implements Document { return { line, character: contentOffset - lineOffsets[line] }; } - update(changes: readonly DocumentChange[], version: number): void { + update(changes: readonly TextDocumentChange[], version: number): void { for (const change of changes) { this.updateContent(change); } this.version = version; } - protected updateContent(change: DocumentChange): void { + updateContent(change: TextDocumentChange): void { if (change.range == null) { this._content = change.text; this._lineOffsets = undefined; @@ -143,14 +135,18 @@ export class TextDocument implements Document { } } - public getLineOffsets(): number[] { + createTextEdit(startOffset: number, endOffset: number, newText: string): TextDocumentEdit[] { + return [{ range: { start: this.positionAt(startOffset), end: this.positionAt(endOffset) }, newText }]; + } + + private getLineOffsets(): number[] { if (this._lineOffsets === undefined) { this._lineOffsets = computeLineOffsets(this._content, true); } return this._lineOffsets; } - public ensureBeforeEndOfLine(offset: number, lineOffset: number): number { + private ensureBeforeEndOfLine(offset: number, lineOffset: number): number { while (offset > lineOffset && (this._content[offset - 1] === '\r' || this._content[offset - 1] === '\n')) { offset--; } diff --git a/packages/core/src/document/text-scripture-document.ts b/packages/core/src/document/text-scripture-document.ts new file mode 100644 index 0000000..029b699 --- /dev/null +++ b/packages/core/src/document/text-scripture-document.ts @@ -0,0 +1,57 @@ +import { Position, Range } from '../common'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; +import { ScriptureSerializer } from './scripture-serializer'; +import { TextDocument } from './text-document'; +import { TextDocumentEdit } from './text-document-edit'; + +export class TextScriptureDocument extends ScriptureDocument { + protected text: TextDocument; + + constructor( + uri: string, + format: string, + version: number, + content: string, + protected readonly serializer: ScriptureSerializer, + children?: ScriptureNode[], + ) { + super(uri, children); + this.text = new TextDocument(uri, format, version, content); + } + + get content(): string { + return this.text.content; + } + + get version(): number { + return this.text.version; + } + + set version(version: number) { + this.text.version = version; + } + + getText(range?: Range): string { + return this.text.getText(range); + } + + offsetAt(position: Position): number { + return this.text.offsetAt(position); + } + + positionAt(offset: number, range?: Range): Position { + return this.text.positionAt(offset, range); + } + + createTextEdit(startOffset: number, endOffset: number, newText: string): TextDocumentEdit[] { + return this.text.createTextEdit(startOffset, endOffset, newText); + } + + createScriptureEdit( + startOffset: number, + endOffset: number, + nodes: ScriptureNode[] | ScriptureNode, + ): TextDocumentEdit[] { + return this.text.createTextEdit(startOffset, endOffset, this.serializer.serialize(nodes)); + } +} diff --git a/packages/core/src/formatting/on-type-formatting-provider.ts b/packages/core/src/formatting/on-type-formatting-provider.ts index 8908918..cb80614 100644 --- a/packages/core/src/formatting/on-type-formatting-provider.ts +++ b/packages/core/src/formatting/on-type-formatting-provider.ts @@ -1,5 +1,4 @@ import { Position } from '../common/position'; -import { TextEdit } from '../common/text-edit'; export interface OnTypeFormattingProvider { readonly id: string; @@ -7,5 +6,5 @@ export interface OnTypeFormattingProvider { readonly onTypeTriggerCharacters: ReadonlySet; init(): Promise; - getOnTypeEdits(uri: string, position: Position, ch: string): Promise; + getOnTypeEdits(uri: string, position: Position, ch: string): Promise; } diff --git a/packages/core/src/workspace/workspace.ts b/packages/core/src/workspace/workspace.ts index a58b10c..f7d9797 100644 --- a/packages/core/src/workspace/workspace.ts +++ b/packages/core/src/workspace/workspace.ts @@ -1,7 +1,6 @@ import { map, merge, Observable, tap } from 'rxjs'; import { Position } from '../common/position'; -import { TextEdit } from '../common/text-edit'; import { Diagnostic } from '../diagnostic/diagnostic'; import { DiagnosticFix } from '../diagnostic/diagnostic-fix'; import { DiagnosticProvider, DiagnosticsChanged } from '../diagnostic/diagnostic-provider'; @@ -75,7 +74,7 @@ export class Workspace { return Array.from(characters); } - async getOnTypeEdits(uri: string, position: Position, ch: string): Promise { + async getOnTypeEdits(uri: string, position: Position, ch: string): Promise { for (const provider of this.onTypeFormattingProviders.values()) { if (provider.onTypeTriggerCharacters.has(ch)) { const edits = await provider.getOnTypeEdits(uri, position, ch); diff --git a/packages/delta/eslint.config.js b/packages/delta/eslint.config.js new file mode 100644 index 0000000..4ef8795 --- /dev/null +++ b/packages/delta/eslint.config.js @@ -0,0 +1,12 @@ +import library from '@repo/eslint-config/library.js'; + +export default [ + { + languageOptions: { + parserOptions: { + project: './tsconfig.json', + }, + }, + }, + ...library, +]; diff --git a/packages/delta/package.json b/packages/delta/package.json new file mode 100644 index 0000000..551a686 --- /dev/null +++ b/packages/delta/package.json @@ -0,0 +1,47 @@ +{ + "name": "@sillsdev/lynx-delta", + "version": "0.0.0", + "description": "", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup-node", + "dev": "tsup-node --watch --sourcemap", + "check-types": "tsc --noEmit", + "lint": "eslint ." + }, + "keywords": [], + "author": "SIL Global", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "*", + "quill-delta": "^5.1.0" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/tsup-config": "*", + "@repo/typescript-config": "*", + "eslint": "^9.9.1", + "tsup": "^8.3.0", + "typescript": "^5.5.4" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ] +} diff --git a/packages/delta/src/delta-document-factory.ts b/packages/delta/src/delta-document-factory.ts new file mode 100644 index 0000000..cd2e0df --- /dev/null +++ b/packages/delta/src/delta-document-factory.ts @@ -0,0 +1,21 @@ +import { DocumentFactory, ScriptureDocument } from '@sillsdev/lynx'; +import Delta, { Op } from 'quill-delta'; + +import { DeltaDocument } from './delta-document'; + +export class DeltaDocumentFactory implements DocumentFactory { + create(uri: string, format: string, version: number, content: Delta): DeltaDocument { + if (format !== 'delta') { + throw new Error(`This factory does not support the format '${format}'.`); + } + return new DeltaDocument(uri, version, content); + } + + update(document: ScriptureDocument, changes: readonly Op[], version: number): DeltaDocument { + if (document instanceof DeltaDocument) { + document.update(changes, version); + return document; + } + throw new Error('The document must be created by this factory.'); + } +} diff --git a/packages/delta/src/delta-document.test.ts b/packages/delta/src/delta-document.test.ts new file mode 100644 index 0000000..cf1dc6f --- /dev/null +++ b/packages/delta/src/delta-document.test.ts @@ -0,0 +1,34 @@ +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { DeltaDocument } from './delta-document'; + +describe('DeltaDocument', () => { + it('update', () => { + const document = new DeltaDocument('uri', 1, new Delta().insert('Hello World!').insert('\n')); + document.update(new Delta().retain(6).delete(5).insert('everybody').ops, 2); + + expect(document.version).toEqual(2); + expect(document.getText()).toEqual([{ insert: 'Hello everybody!\n' }]); + }); + + it('offsetAt', () => { + const document = new DeltaDocument( + 'uri', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.offsetAt({ line: 0, character: 0 })).toEqual(0); + expect(document.offsetAt({ line: 1, character: 0 })).toEqual(6); + expect(document.offsetAt({ line: 2, character: 0 })).toEqual(7); + expect(document.offsetAt({ line: 2, character: 6 })).toEqual(13); + expect(document.offsetAt({ line: 3, character: 0 })).toEqual(14); + }); +}); diff --git a/packages/delta/src/delta-document.ts b/packages/delta/src/delta-document.ts new file mode 100644 index 0000000..0acbc4b --- /dev/null +++ b/packages/delta/src/delta-document.ts @@ -0,0 +1,121 @@ +import { Document, Position, Range } from '@sillsdev/lynx'; +import Delta, { Op } from 'quill-delta'; + +export class DeltaDocument implements Document { + private _content: Delta; + private _lineOffsets: number[] | undefined = undefined; + + constructor( + public readonly uri: string, + public version: number, + content: Delta, + ) { + this._content = content; + } + + get content(): Delta { + return this._content; + } + + update(changes: readonly Op[], version: number): void { + this._content = this._content.compose(new Delta(changes as Op[])); + this._lineOffsets = undefined; + this.version = version; + } + + getText(range?: Range): string { + let content = this._content; + if (range != null) { + const start = this.offsetAt(range.start); + const end = this.offsetAt(range.end); + content = this._content.slice(start, end); + } + return content.map((op) => (typeof op.insert === 'string' ? op.insert : '\ufffc')).join(''); + } + + positionAt(offset: number, range?: Range): Position { + const lineOffsets = this.getLineOffsets(); + if (range == null) { + range = { start: { line: 0, character: 0 }, end: { line: lineOffsets.length - 1, character: 0 } }; + } + + if (range.start.line === range.end.line) { + return { + line: range.start.line, + character: Math.min(range.start.character + offset, range.end.character), + }; + } + + const startOffset = this.offsetAt(range.start); + const endOffset = this.offsetAt(range.end); + if (startOffset === endOffset) { + return range.start; + } + let contentOffset = startOffset + offset; + contentOffset = Math.max(Math.min(contentOffset, endOffset), 0); + + let low = 0; + let high = range.end.line + 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > contentOffset) { + high = mid; + } else { + low = mid + 1; + } + } + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + + return { line, character: contentOffset - lineOffsets[line] }; + } + + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + if (position.line >= lineOffsets.length) { + return lineOffsets[lineOffsets.length - 1] + 1; + } else if (position.line < 0) { + return 0; + } + const lineOffset = lineOffsets[position.line]; + if (position.character <= 0) { + return lineOffset; + } + + const nextLineOffset = + position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : lineOffsets[lineOffsets.length - 1] + 1; + return Math.min(lineOffset + position.character, nextLineOffset); + } + + createTextEdit(startOffset: number, endOffset: number, newText: string): Op[] { + const ops: Op[] = []; + if (startOffset > 0) { + ops.push({ retain: startOffset }); + } + if (endOffset - startOffset > 0) { + ops.push({ delete: endOffset - startOffset }); + } + if (newText.length > 0) { + ops.push({ insert: newText }); + } + return ops; + } + + private getLineOffsets(): number[] { + if (this._lineOffsets === undefined) { + this._lineOffsets = computeLineOffsets(this._content, true); + } + return this._lineOffsets; + } +} + +function computeLineOffsets(delta: Delta, isAtLineStart: boolean, textOffset = 0): number[] { + const result: number[] = isAtLineStart ? [textOffset] : []; + let i = textOffset; + delta.eachLine((line) => { + i += line.length() + 1; + result.push(i); + }); + return result; +} diff --git a/packages/delta/tsconfig.json b/packages/delta/tsconfig.json new file mode 100644 index 0000000..25c68df --- /dev/null +++ b/packages/delta/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/base.json", + + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/delta/tsup.config.js b/packages/delta/tsup.config.js new file mode 100644 index 0000000..156f84e --- /dev/null +++ b/packages/delta/tsup.config.js @@ -0,0 +1,4 @@ +import library from '@repo/tsup-config/library.js'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ ...library() }); diff --git a/packages/examples/src/simple-quote-formatting-provider.ts b/packages/examples/src/simple-quote-formatting-provider.ts index 437ed9f..c0b24c4 100644 --- a/packages/examples/src/simple-quote-formatting-provider.ts +++ b/packages/examples/src/simple-quote-formatting-provider.ts @@ -1,30 +1,28 @@ -import { DocumentManager, OnTypeFormattingProvider, Position, TextDocument, TextEdit } from '@sillsdev/lynx'; +import { DocumentManager, OnTypeFormattingProvider, Position } from '@sillsdev/lynx'; export class SimpleQuoteFormattingProvider implements OnTypeFormattingProvider { readonly id = 'simple-quote'; readonly onTypeTriggerCharacters: ReadonlySet = new Set(['"', '“', '”']); - constructor(private readonly documentManager: DocumentManager) {} + constructor(private readonly documentManager: DocumentManager) {} init(): Promise { return Promise.resolve(); } - async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { + async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { const doc = await this.documentManager.get(uri); if (doc == null) { return undefined; } - const edits: TextEdit[] = []; + const edits: unknown[] = []; const text = doc.getText(); for (const match of text.matchAll(/["“”]/g)) { if ((match.index === 0 || text[match.index - 1].trim() === '') && match[0] !== '“') { - const pos = doc.positionAt(match.index); - edits.push({ range: { start: pos, end: { line: pos.line, character: pos.character + 1 } }, newText: '“' }); + edits.push(...doc.createTextEdit(match.index, match.index + 1, '“')); } else if ((match.index === text.length - 1 || text[match.index + 1].trim() === '') && match[0] !== '”') { - const pos = doc.positionAt(match.index); - edits.push({ range: { start: pos, end: { line: pos.line, character: pos.character + 1 } }, newText: '”' }); + edits.push(...doc.createTextEdit(match.index, match.index + 1, '”')); } } diff --git a/packages/examples/src/verse-order-diagnostic-provider.test.ts b/packages/examples/src/verse-order-diagnostic-provider.test.ts index 175d686..039a3de 100644 --- a/packages/examples/src/verse-order-diagnostic-provider.test.ts +++ b/packages/examples/src/verse-order-diagnostic-provider.test.ts @@ -4,10 +4,12 @@ import { Localizer, ScriptureChapter, ScriptureDocument, + ScriptureNode, ScriptureParagraph, ScriptureSerializer, ScriptureText, ScriptureVerse, + TextScriptureDocument, } from '@sillsdev/lynx'; import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -18,28 +20,26 @@ describe('VerseOrderDiagnosticProvider', () => { it('out of order', async () => { const env = new TestEnvironment(); await env.init(); - env.docManager.add( - new ScriptureDocument('file1', 1, '', [ - new ScriptureChapter('1'), - new ScriptureParagraph('p', [ - new ScriptureVerse('1', { - start: { line: 2, character: 0 }, - end: { line: 2, character: 4 }, - }), - new ScriptureText('Chapter one, verse one.'), - new ScriptureVerse('3', { - start: { line: 3, character: 0 }, - end: { line: 3, character: 4 }, - }), - new ScriptureText('Chapter one, verse three.'), - new ScriptureVerse('2', { - start: { line: 4, character: 0 }, - end: { line: 4, character: 4 }, - }), - new ScriptureText('Chapter one, verse two.'), - ]), + env.addDocument('file1', [ + new ScriptureChapter('1'), + new ScriptureParagraph('p', [ + new ScriptureVerse('1', { + start: { line: 2, character: 0 }, + end: { line: 2, character: 4 }, + }), + new ScriptureText('Chapter one, verse one.'), + new ScriptureVerse('3', { + start: { line: 3, character: 0 }, + end: { line: 3, character: 4 }, + }), + new ScriptureText('Chapter one, verse three.'), + new ScriptureVerse('2', { + start: { line: 4, character: 0 }, + end: { line: 4, character: 4 }, + }), + new ScriptureText('Chapter one, verse two.'), ]), - ); + ]); const diagnostics = await env.provider.getDiagnostics('file1'); @@ -51,23 +51,21 @@ describe('VerseOrderDiagnosticProvider', () => { it('missing verse', async () => { const env = new TestEnvironment(); await env.init(); - env.docManager.add( - new ScriptureDocument('file1', 1, '', [ - new ScriptureChapter('1'), - new ScriptureParagraph('p', [ - new ScriptureVerse('1', { - start: { line: 2, character: 0 }, - end: { line: 2, character: 4 }, - }), - new ScriptureText('Chapter one, verse one.'), - new ScriptureVerse('3', { - start: { line: 3, character: 0 }, - end: { line: 3, character: 4 }, - }), - new ScriptureText('Chapter one, verse three.'), - ]), + env.addDocument('file1', [ + new ScriptureChapter('1'), + new ScriptureParagraph('p', [ + new ScriptureVerse('1', { + start: { line: 2, character: 0 }, + end: { line: 2, character: 4 }, + }), + new ScriptureText('Chapter one, verse one.'), + new ScriptureVerse('3', { + start: { line: 3, character: 0 }, + end: { line: 3, character: 4 }, + }), + new ScriptureText('Chapter one, verse three.'), ]), - ); + ]); const diagnostics = await env.provider.getDiagnostics('file1'); @@ -79,17 +77,23 @@ describe('VerseOrderDiagnosticProvider', () => { class TestEnvironment { private readonly localizer: Localizer; + private readonly serializer: ScriptureSerializer; readonly docManager: DocumentManager; readonly provider: VerseOrderDiagnosticProvider; constructor() { this.localizer = new Localizer(); + this.serializer = mock(); this.docManager = new DocumentManager(mock>()); - this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager, mock()); + this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager); } async init(): Promise { await this.provider.init(); await this.localizer.init(); } + + addDocument(uri: string, nodes: ScriptureNode[]): void { + this.docManager.add(new TextScriptureDocument(uri, 'text', 1, '', this.serializer, nodes)); + } } diff --git a/packages/examples/src/verse-order-diagnostic-provider.ts b/packages/examples/src/verse-order-diagnostic-provider.ts index 28e518c..90e05db 100644 --- a/packages/examples/src/verse-order-diagnostic-provider.ts +++ b/packages/examples/src/verse-order-diagnostic-provider.ts @@ -9,7 +9,6 @@ import { ScriptureChapter, ScriptureDocument, ScriptureNodeType, - ScriptureSerializer, ScriptureVerse, } from '@sillsdev/lynx'; import { map, merge, Observable, switchMap } from 'rxjs'; @@ -21,7 +20,6 @@ export class VerseOrderDiagnosticProvider implements DiagnosticProvider { constructor( private readonly localizer: Localizer, private readonly documentManager: DocumentManager, - private readonly serializer: ScriptureSerializer, ) { this.diagnosticsChanged$ = merge( documentManager.opened$.pipe( @@ -63,20 +61,20 @@ export class VerseOrderDiagnosticProvider implements DiagnosticProvider { return this.validateDocument(doc); } - getDiagnosticFixes(_uri: string, diagnostic: Diagnostic): Promise { + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise { + const doc = await this.documentManager.get(uri); + if (doc == null) { + return []; + } const fixes: DiagnosticFix[] = []; if (diagnostic.code === 2) { const verseNumber = diagnostic.data as number; + const offset = doc.offsetAt(diagnostic.range.start); fixes.push({ title: this.localizer.t('missingVerse.fixTitle', { ns: 'verseOrder' }), isPreferred: true, diagnostic, - edits: [ - { - range: { start: diagnostic.range.start, end: diagnostic.range.start }, - newText: this.serializer.serialize(new ScriptureVerse(verseNumber.toString())), - }, - ], + edits: doc.createScriptureEdit(offset, offset, new ScriptureVerse(verseNumber.toString())), }); } return Promise.resolve(fixes); diff --git a/packages/usfm/src/usfm-document-factory.ts b/packages/usfm/src/usfm-document-factory.ts index 5526a00..b9d12b2 100644 --- a/packages/usfm/src/usfm-document-factory.ts +++ b/packages/usfm/src/usfm-document-factory.ts @@ -1,19 +1,23 @@ -import { DocumentChange, DocumentFactory, ScriptureDocument } from '@sillsdev/lynx'; +import { DocumentFactory, ScriptureDocument, TextDocumentChange } from '@sillsdev/lynx'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { UsfmDocument } from './usfm-document'; +import { UsfmScriptureSerializer } from './usfm-scripture-serializer'; export class UsfmDocumentFactory implements DocumentFactory { - constructor(private readonly styleSheet: UsfmStylesheet) {} + private readonly serializer: UsfmScriptureSerializer; + constructor(private readonly styleSheet: UsfmStylesheet) { + this.serializer = new UsfmScriptureSerializer(styleSheet); + } - create(uri: string, format: string, version: number, content: string): ScriptureDocument { + create(uri: string, format: string, version: number, content: string): UsfmDocument { if (format !== 'usfm') { throw new Error(`This factory does not support the format '${format}'.`); } - return new UsfmDocument(uri, version, content, this.styleSheet); + return new UsfmDocument(uri, version, content, this.styleSheet, this.serializer); } - update(document: ScriptureDocument, changes: readonly DocumentChange[], version: number): ScriptureDocument { + update(document: ScriptureDocument, changes: readonly TextDocumentChange[], version: number): UsfmDocument { if (document instanceof UsfmDocument) { document.update(changes, version); return document; diff --git a/packages/usfm/src/usfm-document.test.ts b/packages/usfm/src/usfm-document.test.ts index 03082fd..e2ae272 100644 --- a/packages/usfm/src/usfm-document.test.ts +++ b/packages/usfm/src/usfm-document.test.ts @@ -3,10 +3,12 @@ import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { describe, expect, it } from 'vitest'; import { UsfmDocument } from './usfm-document'; +import { UsfmScriptureSerializer } from './usfm-scripture-serializer'; describe('UsfmDocument', () => { it('create', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -15,7 +17,7 @@ describe('UsfmDocument', () => { \\p \\v 2 This is a test. `; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); expect(document.children.length).toEqual(4); @@ -37,6 +39,7 @@ describe('UsfmDocument', () => { it('update full document', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -45,7 +48,7 @@ describe('UsfmDocument', () => { \\p \\v 2 This is a test. `; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); expect(document.children.length).toEqual(4); @@ -82,6 +85,7 @@ describe('UsfmDocument', () => { it('update single line in a paragraph', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -90,7 +94,7 @@ describe('UsfmDocument', () => { \\p \\v 2 This is a test. `; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); document.update( [{ range: { start: { line: 4, character: 15 }, end: { line: 4, character: 19 } }, text: 'test again' }], @@ -112,6 +116,7 @@ describe('UsfmDocument', () => { it('add new paragraph', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -119,7 +124,7 @@ describe('UsfmDocument', () => { \\v 1 This is a test. \\p \\v 2 This is a test.`; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); document.update( [ @@ -144,6 +149,7 @@ describe('UsfmDocument', () => { it('add line at end of a paragraph', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -151,7 +157,7 @@ describe('UsfmDocument', () => { \\v 1 This is a test. \\p \\v 2 This is a test.`; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); document.update( [ @@ -176,6 +182,7 @@ describe('UsfmDocument', () => { it('update multiple paragraphs', () => { const stylesheet = new UsfmStylesheet('usfm.sty'); + const serializer = new UsfmScriptureSerializer(stylesheet); const usfm = `\\id MAT \\c 1 @@ -183,7 +190,7 @@ describe('UsfmDocument', () => { \\v 1 This is a test. \\p \\v 2 This is a test.`; - const document = new UsfmDocument('uri', 1, usfm, stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); document.update( [ diff --git a/packages/usfm/src/usfm-document.ts b/packages/usfm/src/usfm-document.ts index 2e1bf06..adfadc4 100644 --- a/packages/usfm/src/usfm-document.ts +++ b/packages/usfm/src/usfm-document.ts @@ -1,12 +1,10 @@ import { - DocumentChange, Position, Range, ScriptureBook, ScriptureCell, ScriptureChapter, ScriptureCharacterStyle, - ScriptureDocument, ScriptureMilestone, ScriptureNode, ScriptureNote, @@ -14,10 +12,13 @@ import { ScriptureParagraph, ScriptureRef, ScriptureRow, + ScriptureSerializer, ScriptureSidebar, ScriptureTable, ScriptureText, ScriptureVerse, + TextDocumentChange, + TextScriptureDocument, } from '@sillsdev/lynx'; import { UsfmAttribute, @@ -29,7 +30,7 @@ import { UsfmTokenType, } from '@sillsdev/machine/corpora'; -export class UsfmDocument extends ScriptureDocument { +export class UsfmDocument extends TextScriptureDocument { private lineChildren: number[] = []; constructor( @@ -37,13 +38,14 @@ export class UsfmDocument extends ScriptureDocument { version: number, content: string, private readonly stylesheet: UsfmStylesheet, + serializer: ScriptureSerializer, start: Position = { line: 0, character: 0 }, ) { - super(uri, version, content); + super(uri, 'usfm', version, content, serializer); this.parseUsfm(content, start); } - update(changes: readonly DocumentChange[], version: number): void { + update(changes: readonly TextDocumentChange[], version: number): void { for (const change of changes) { if (change.range == null) { this.parseUsfm(change.text); @@ -71,7 +73,14 @@ export class UsfmDocument extends ScriptureDocument { this.content.substring(childStartOffset, changeStartOffset) + change.text + this.content.substring(changeEndOffset, childEndOffset); - const subDocument = new UsfmDocument(this.uri, version, usfm, this.stylesheet, startChild.range.start); + const subDocument = new UsfmDocument( + this.uri, + version, + usfm, + this.stylesheet, + this.serializer, + startChild.range.start, + ); // update nodes this.spliceChildren(startChildIndex, endChildIndex - startChildIndex + 1, ...subDocument.children); @@ -106,7 +115,7 @@ export class UsfmDocument extends ScriptureDocument { } } } - this.updateContent(change); + this.text.updateContent(change); } this.version = version; } diff --git a/packages/usfm/src/usfm-scripture-serializer.test.ts b/packages/usfm/src/usfm-scripture-serializer.test.ts index 92bf20e..539ac78 100644 --- a/packages/usfm/src/usfm-scripture-serializer.test.ts +++ b/packages/usfm/src/usfm-scripture-serializer.test.ts @@ -57,8 +57,8 @@ describe('UsfmScriptureSerializer', () => { function serialize(usfm: string): string { const stylesheet = new UsfmStylesheet('usfm.sty'); - const document = new UsfmDocument('uri', 1, usfm, stylesheet); const serializer = new UsfmScriptureSerializer(stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); return normalize(serializer.serialize(document)); } diff --git a/packages/vscode/src/server.ts b/packages/vscode/src/server.ts index d085c20..e0fee7b 100644 --- a/packages/vscode/src/server.ts +++ b/packages/vscode/src/server.ts @@ -1,6 +1,6 @@ import { Diagnostic, DocumentManager, Localizer, ScriptureDocument, Workspace } from '@sillsdev/lynx'; import { SimpleQuoteFormattingProvider, VerseOrderDiagnosticProvider } from '@sillsdev/lynx-examples'; -import { UsfmDocumentFactory, UsfmScriptureSerializer } from '@sillsdev/lynx-usfm'; +import { UsfmDocumentFactory } from '@sillsdev/lynx-usfm'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { CodeAction, @@ -12,6 +12,7 @@ import { InitializeResult, ProposedFeatures, TextDocumentSyncKind, + TextEdit, } from 'vscode-languageserver/node'; // Create a connection for the server, using Node's IPC as a transport. @@ -21,11 +22,10 @@ const connection = createConnection(ProposedFeatures.all); const localizer = new Localizer(); const stylesheet = new UsfmStylesheet('usfm.sty'); const documentFactory = new UsfmDocumentFactory(stylesheet); -const scriptureSerializer = new UsfmScriptureSerializer(stylesheet); const documentManager = new DocumentManager(documentFactory); const workspace = new Workspace({ localizer, - diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager, scriptureSerializer)], + diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager)], onTypeFormattingProviders: [new SimpleQuoteFormattingProvider(documentManager)], }); @@ -86,7 +86,7 @@ connection.onCodeAction(async (params) => { isPreferred: fix.isPreferred, edit: { changes: { - [params.textDocument.uri]: fix.edits, + [params.textDocument.uri]: fix.edits as TextEdit[], }, }, })), @@ -113,7 +113,7 @@ connection.onDidChangeTextDocument((params) => { }); connection.onDocumentOnTypeFormatting(async (params) => { - return await workspace.getOnTypeEdits(params.textDocument.uri, params.position, params.ch); + return (await workspace.getOnTypeEdits(params.textDocument.uri, params.position, params.ch)) as TextEdit[]; }); // Listen on the connection