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/diagnostic/diagnostic-fix.ts b/packages/core/src/diagnostic/diagnostic-fix.ts index a6cfb6a..5d45822 100644 --- a/packages/core/src/diagnostic/diagnostic-fix.ts +++ b/packages/core/src/diagnostic/diagnostic-fix.ts @@ -1,9 +1,9 @@ import { TextEdit } from '../common/text-edit'; import { Diagnostic } from './diagnostic'; -export interface DiagnosticFix { +export interface DiagnosticFix { title: string; diagnostic: Diagnostic; isPreferred?: boolean; - edits: TextEdit[]; + edits: T[]; } diff --git a/packages/core/src/diagnostic/diagnostic-provider.ts b/packages/core/src/diagnostic/diagnostic-provider.ts index ce66ad4..9c4f70b 100644 --- a/packages/core/src/diagnostic/diagnostic-provider.ts +++ b/packages/core/src/diagnostic/diagnostic-provider.ts @@ -1,5 +1,6 @@ import { Observable } from 'rxjs'; +import { TextEdit } from '../common/text-edit'; import { Diagnostic } from './diagnostic'; import { DiagnosticFix } from './diagnostic-fix'; @@ -9,10 +10,10 @@ export interface DiagnosticsChanged { diagnostics: Diagnostic[]; } -export interface DiagnosticProvider { +export interface DiagnosticProvider { readonly id: string; readonly diagnosticsChanged$: Observable; init(): Promise; getDiagnostics(uri: string): Promise; - getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise; + getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]>; } diff --git a/packages/core/src/document/document-accessor.ts b/packages/core/src/document/document-accessor.ts new file mode 100644 index 0000000..4b73392 --- /dev/null +++ b/packages/core/src/document/document-accessor.ts @@ -0,0 +1,35 @@ +import { Observable } from 'rxjs'; + +import { Document } from './document'; + +export interface DocumentCreated { + document: T; +} + +export interface DocumentClosed { + uri: string; +} + +export interface DocumentOpened { + document: T; +} + +export interface DocumentDeleted { + uri: string; +} + +export interface DocumentChanged { + document: T; +} + +export interface DocumentAccessor { + readonly created$: Observable>; + readonly closed$: Observable; + readonly opened$: Observable>; + readonly deleted$: Observable; + readonly changed$: Observable>; + + get(uri: string): Promise; + all(): Promise; + active(): Promise; +} diff --git a/packages/core/src/document/document-factory.ts b/packages/core/src/document/document-factory.ts index 5b97704..560c496 100644 --- a/packages/core/src/document/document-factory.ts +++ b/packages/core/src/document/document-factory.ts @@ -1,12 +1,7 @@ -import { Range } from '../common/range'; import { Document } from './document'; +import { TextDocumentChange } from './text-document-change'; -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: string): TDoc; + update(document: TDoc, changes: readonly TChange[], version: number): TDoc; } diff --git a/packages/core/src/document/document-manager.test.ts b/packages/core/src/document/document-manager.test.ts index 9db362a..90de374 100644 --- a/packages/core/src/document/document-manager.test.ts +++ b/packages/core/src/document/document-manager.test.ts @@ -1,102 +1,97 @@ +import { firstValueFrom } from 'rxjs'; import { describe, expect, it } from 'vitest'; import { mock, MockProxy } from 'vitest-mock-extended'; -import { Document } from './document'; -import { DocumentFactory } from './document-factory'; import { DocumentManager } from './document-manager'; import { DocumentReader } from './document-reader'; +import { TextDocument } from './text-document'; +import { TextDocumentFactory } from './text-document-factory'; describe('DocumentManager', () => { it('all', async () => { const env = new TestEnvironment(); - await expect(env.docManager.all()).resolves.toEqual([ - { uri: 'file1', format: 'plaintext', version: 1, content: 'This is file1.' }, - { uri: 'file2', format: 'plaintext', version: 1, content: 'This is file2.' }, - ]); + const docs = await env.docManager.all(); + expect(docs).toHaveLength(2); + expect(docs[0].content).toEqual('This is file1.'); + expect(docs[1].content).toEqual('This is file2.'); }); it('get', async () => { const env = new TestEnvironment(); - await expect(env.docManager.get('file2')).resolves.toEqual({ - uri: 'file2', - format: 'plaintext', - version: 1, - content: 'This is file2.', - }); + const doc = await env.docManager.get('file2'); + expect(doc).not.toBeNull(); + expect(doc?.content).toEqual('This is file2.'); }); it('fire created event', async () => { const env = new TestEnvironment(); - expect.assertions(1); - env.docManager.created$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 1, content: 'This is file1.' }); - }); + const createdPromise = firstValueFrom(env.docManager.created$); await env.docManager.fireCreated('file1'); + const createdEvent = await createdPromise; + expect(createdEvent.document.uri).toEqual('file1'); + expect(createdEvent.document.content).toEqual('This is file1.'); }); it('fire opened event', async () => { const env = new TestEnvironment(); - expect.assertions(3); - env.docManager.opened$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 1, content: 'This is opened file1.' }); - }); + const openedPromise = firstValueFrom(env.docManager.opened$); await expect(env.docManager.active()).resolves.toHaveLength(0); await env.docManager.fireOpened('file1', 'plaintext', 1, 'This is opened file1.'); await expect(env.docManager.active()).resolves.toHaveLength(1); + const openedEvent = await openedPromise; + expect(openedEvent.document.uri).toEqual('file1'); + expect(openedEvent.document.content).toEqual('This is opened file1.'); }); it('fire closed event', async () => { const env = new TestEnvironment(); await env.docManager.fireOpened('file1', 'plaintext', 1, 'content'); - expect.assertions(3); - env.docManager.closed$.subscribe((e) => { - expect(e.uri).toEqual('file1'); - }); + const closedPromise = firstValueFrom(env.docManager.closed$); await expect(env.docManager.active()).resolves.toHaveLength(1); await env.docManager.fireClosed('file1'); await expect(env.docManager.active()).resolves.toHaveLength(0); + const closedEvent = await closedPromise; + expect(closedEvent.uri).toEqual('file1'); }); it('fire deleted event', async () => { const env = new TestEnvironment(); - expect.assertions(2); - env.docManager.deleted$.subscribe((e) => { - expect(e.uri).toEqual('file1'); - }); + const deletedPromise = firstValueFrom(env.docManager.deleted$); await env.docManager.fireDeleted('file1'); env.docReader.keys.mockReturnValue(['file2']); await expect(env.docManager.all()).resolves.toHaveLength(1); + const deletedEvent = await deletedPromise; + expect(deletedEvent.uri).toEqual('file1'); }); it('fire changed event', async () => { const env = new TestEnvironment(); - expect.assertions(2); - const sub = env.docManager.changed$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 2, content: 'This is changed file1.' }); - }); + const changedPromise = firstValueFrom(env.docManager.changed$); await env.docManager.fireChanged('file1', [{ text: 'This is changed file1.' }], 2); - sub.unsubscribe(); + const changedEvent = await changedPromise; + expect(changedEvent.document.uri).toEqual('file1'); + expect(changedEvent.document.version).toEqual(2); + expect(changedEvent.document.content).toEqual('This is changed file1.'); + + // reload document from reader await env.docManager.fireChanged('file1'); - await expect(env.docManager.get('file1')).resolves.toEqual({ - uri: 'file1', - format: 'plaintext', - version: 1, - content: 'This is file1.', - }); + const doc = await env.docManager.get('file1'); + expect(doc).not.toBeNull(); + expect(doc?.content).toEqual('This is file1.'); }); }); class TestEnvironment { readonly docReader: MockProxy; - readonly docFactory: MockProxy>; - readonly docManager: DocumentManager; + readonly docFactory: TextDocumentFactory; + readonly docManager: DocumentManager; constructor() { this.docReader = mock(); @@ -104,15 +99,7 @@ class TestEnvironment { return Promise.resolve({ format: 'plaintext', version: 1, content: `This is ${uri}.` }); }); this.docReader.keys.mockReturnValue(['file1', 'file2']); - - this.docFactory = mock>(); - this.docFactory.create.mockImplementation((uri, format, version, content) => { - return { uri, format, version, content }; - }); - this.docFactory.update.mockImplementation((document, changes, version) => { - return { uri: document.uri, format: 'plaintext', version, content: changes[0].text }; - }); - + this.docFactory = new TextDocumentFactory(); this.docManager = new DocumentManager(this.docFactory, this.docReader); } } diff --git a/packages/core/src/document/document-manager.ts b/packages/core/src/document/document-manager.ts index 4f9a5ab..de9a2eb 100644 --- a/packages/core/src/document/document-manager.ts +++ b/packages/core/src/document/document-manager.ts @@ -1,44 +1,35 @@ import { Observable, Subject } from 'rxjs'; import { Document } from './document'; -import { DocumentChange, DocumentFactory } from './document-factory'; +import { + DocumentAccessor, + DocumentChanged, + DocumentClosed, + DocumentCreated, + DocumentDeleted, + DocumentOpened, +} from './document-accessor'; +import { DocumentFactory } from './document-factory'; import { DocumentReader } from './document-reader'; +import { TextDocumentChange } from './text-document-change'; -export interface DocumentCreated { - document: T; -} - -export interface DocumentClosed { - uri: string; -} - -export interface DocumentOpened { - document: T; -} - -export interface DocumentDeleted { - uri: string; -} - -export interface DocumentChanged { - document: T; -} - -export class DocumentManager { - private readonly documents = new Map(); +export class DocumentManager + implements DocumentAccessor +{ + private readonly documents = new Map(); private readonly activeDocuments = new Set(); - private readonly createdSubject = new Subject>(); + private readonly createdSubject = new Subject>(); private readonly closedSubject = new Subject(); - private readonly openedSubject = new Subject>(); + private readonly openedSubject = new Subject>(); private readonly deletedSubject = new Subject(); - private readonly changedSubject = new Subject>(); + private readonly changedSubject = new Subject>(); constructor( - private readonly factory: DocumentFactory, + private readonly factory: DocumentFactory, private readonly reader?: DocumentReader, ) {} - get created$(): Observable> { + get created$(): Observable> { return this.createdSubject.asObservable(); } @@ -46,7 +37,7 @@ export class DocumentManager { return this.closedSubject.asObservable(); } - get opened$(): Observable> { + get opened$(): Observable> { return this.openedSubject.asObservable(); } @@ -54,16 +45,16 @@ export class DocumentManager { return this.deletedSubject.asObservable(); } - get changed$(): Observable> { + get changed$(): Observable> { return this.changedSubject.asObservable(); } - add(doc: T): void { + add(doc: TDoc): void { this.documents.set(doc.uri, doc); this.activeDocuments.add(doc.uri); } - async get(uri: string): Promise { + async get(uri: string): Promise { let doc = this.documents.get(uri); if (doc == null) { doc = await this.reload(uri); @@ -71,7 +62,7 @@ export class DocumentManager { return doc; } - async all(): Promise { + async all(): Promise { const docs = Array.from(this.documents.values()); if (this.reader != null) { for (const id of this.reader.keys()) { @@ -85,8 +76,8 @@ export class DocumentManager { return docs; } - async active(): Promise { - const docs: T[] = []; + async active(): Promise { + const docs: TDoc[] = []; for (const uri of this.activeDocuments) { const doc = await this.get(uri); if (doc != null) { @@ -124,8 +115,8 @@ export class DocumentManager { return Promise.resolve(); } - async fireChanged(uri: string, changes?: readonly DocumentChange[], version?: number): Promise { - let doc: T | undefined = undefined; + async fireChanged(uri: string, changes?: readonly TChange[], version?: number): Promise { + let doc: TDoc | undefined = undefined; if (changes == null) { doc = await this.reload(uri); } else { @@ -140,7 +131,7 @@ export class DocumentManager { } } - private async reload(uri: string): Promise { + private async reload(uri: string): Promise { const data = await this.reader?.read(uri); const doc = data == null ? undefined : this.factory.create(uri, data.format, data.version, data.content); if (doc != null) { diff --git a/packages/core/src/document/document.ts b/packages/core/src/document/document.ts index 70509bb..983bc00 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; + readonly format: string; + + getText(): string; } diff --git a/packages/core/src/document/edit-factory.ts b/packages/core/src/document/edit-factory.ts new file mode 100644 index 0000000..049e6de --- /dev/null +++ b/packages/core/src/document/edit-factory.ts @@ -0,0 +1,6 @@ +import { TextEdit } from '../common/text-edit'; +import { Document } from './document'; + +export interface EditFactory { + createTextEdit(document: TDoc, startOffset: number, endOffset: number, newText: string): TEdit[]; +} diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts index bc14b54..1775896 100644 --- a/packages/core/src/document/index.ts +++ b/packages/core/src/document/index.ts @@ -1,7 +1,16 @@ export type { Document } from './document'; -export type { DocumentChange, DocumentFactory } from './document-factory'; +export type { + DocumentAccessor, + DocumentChanged, + DocumentClosed, + DocumentCreated, + DocumentDeleted, + DocumentOpened, +} from './document-accessor'; +export type { DocumentFactory } from './document-factory'; export { DocumentManager } from './document-manager'; export type { DocumentReader } from './document-reader'; +export type { EditFactory } from './edit-factory'; export { ScriptureBook } from './scripture-book'; export { ScriptureCell } from './scripture-cell'; export { ScriptureChapter } from './scripture-chapter'; @@ -9,6 +18,7 @@ export { ScriptureCharacterStyle } from './scripture-character-style'; export { ScriptureContainer } from './scripture-container'; export type { ScriptureNode } from './scripture-document'; export { ScriptureDocument, ScriptureNodeType } from './scripture-document'; +export type { ScriptureEditFactory } from './scripture-edit-factory'; export { ScriptureLeaf } from './scripture-leaf'; export { ScriptureMilestone } from './scripture-milestone'; export { ScriptureNote } from './scripture-note'; @@ -16,10 +26,13 @@ export { ScriptureOptBreak } from './scripture-optbreak'; export { ScriptureParagraph } from './scripture-paragraph'; export { ScriptureRef } from './scripture-ref'; export { ScriptureRow } from './scripture-row'; -export type { ScriptureSerializer } from './scripture-serializer'; export { ScriptureSidebar } from './scripture-sidebar'; 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 { TextDocumentFactory } from './text-document-factory'; +export { TextEditFactory } from './text-edit-factory'; +export { TextScriptureDocument } from './text-scripture-document'; +export { TextScriptureEditFactory } from './text-scripture-edit-factory'; diff --git a/packages/core/src/document/scripture-document.ts b/packages/core/src/document/scripture-document.ts index 1496791..91f5695 100644 --- a/packages/core/src/document/scripture-document.ts +++ b/packages/core/src/document/scripture-document.ts @@ -1,23 +1,21 @@ 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; + abstract readonly format: string; 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 +76,10 @@ 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; } export enum ScriptureNodeType { diff --git a/packages/core/src/document/scripture-edit-factory.ts b/packages/core/src/document/scripture-edit-factory.ts new file mode 100644 index 0000000..888d826 --- /dev/null +++ b/packages/core/src/document/scripture-edit-factory.ts @@ -0,0 +1,13 @@ +import { TextEdit } from '../common/text-edit'; +import { EditFactory } from './edit-factory'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; + +export interface ScriptureEditFactory + extends EditFactory { + createScriptureEdit( + document: TDoc, + startOffset: number, + endOffset: number, + nodes: ScriptureNode[] | ScriptureNode, + ): TEdit[]; +} diff --git a/packages/core/src/document/scripture-serializer.ts b/packages/core/src/document/scripture-serializer.ts deleted file mode 100644 index cad6b90..0000000 --- a/packages/core/src/document/scripture-serializer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ScriptureNode } from './scripture-document'; - -export interface ScriptureSerializer { - serialize(nodes: ScriptureNode[] | ScriptureNode): string; -} 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-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..f21eeeb 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 { TextEdit } from '../common/text-edit'; import { Document } from './document'; -import { DocumentChange } from './document-factory'; +import { TextDocumentChange } from './text-document-change'; 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): TextEdit[] { + 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-edit-factory.ts b/packages/core/src/document/text-edit-factory.ts new file mode 100644 index 0000000..f383f42 --- /dev/null +++ b/packages/core/src/document/text-edit-factory.ts @@ -0,0 +1,9 @@ +import { TextEdit } from '../common/text-edit'; +import { EditFactory } from './edit-factory'; +import { TextDocument } from './text-document'; + +export class TextEditFactory implements EditFactory { + createTextEdit(document: TextDocument, startOffset: number, endOffset: number, newText: string): TextEdit[] { + return [{ range: { start: document.positionAt(startOffset), end: document.positionAt(endOffset) }, newText }]; + } +} 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..32e8e9d --- /dev/null +++ b/packages/core/src/document/text-scripture-document.ts @@ -0,0 +1,40 @@ +import { Position, Range } from '../common'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; +import { TextDocument } from './text-document'; + +export class TextScriptureDocument extends ScriptureDocument { + protected text: TextDocument; + + constructor(uri: string, format: string, version: number, content: string, children?: ScriptureNode[]) { + super(uri, children); + this.text = new TextDocument(uri, format, version, content); + } + + get version(): number { + return this.text.version; + } + + set version(version: number) { + this.text.version = version; + } + + get format(): string { + return this.text.format; + } + + get content(): string { + return this.text.content; + } + + 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); + } +} diff --git a/packages/core/src/document/text-scripture-edit-factory.ts b/packages/core/src/document/text-scripture-edit-factory.ts new file mode 100644 index 0000000..7fd627c --- /dev/null +++ b/packages/core/src/document/text-scripture-edit-factory.ts @@ -0,0 +1,17 @@ +import { TextEdit } from '../common'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; +import { ScriptureEditFactory } from './scripture-edit-factory'; +import { TextScriptureDocument } from './text-scripture-document'; + +export abstract class TextScriptureEditFactory implements ScriptureEditFactory { + createTextEdit(document: T, startOffset: number, endOffset: number, newText: string): TextEdit[] { + return [{ range: { start: document.positionAt(startOffset), end: document.positionAt(endOffset) }, newText }]; + } + + abstract createScriptureEdit( + document: ScriptureDocument, + startOffset: number, + endOffset: number, + nodes: ScriptureNode[] | ScriptureNode, + ): TextEdit[]; +} diff --git a/packages/core/src/formatting/on-type-formatting-provider.ts b/packages/core/src/formatting/on-type-formatting-provider.ts index 8908918..94d72c8 100644 --- a/packages/core/src/formatting/on-type-formatting-provider.ts +++ b/packages/core/src/formatting/on-type-formatting-provider.ts @@ -1,11 +1,11 @@ import { Position } from '../common/position'; import { TextEdit } from '../common/text-edit'; -export interface OnTypeFormattingProvider { +export interface OnTypeFormattingProvider { readonly id: string; 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..9eb94d6 100644 --- a/packages/core/src/workspace/workspace.ts +++ b/packages/core/src/workspace/workspace.ts @@ -8,21 +8,21 @@ import { DiagnosticProvider, DiagnosticsChanged } from '../diagnostic/diagnostic import { OnTypeFormattingProvider } from '../formatting/on-type-formatting-provider'; import { Localizer } from './localizer'; -export interface WorkspaceConfig { +export interface WorkspaceConfig { localizer: Localizer; - diagnosticProviders?: DiagnosticProvider[]; - onTypeFormattingProviders?: OnTypeFormattingProvider[]; + diagnosticProviders?: DiagnosticProvider[]; + onTypeFormattingProviders?: OnTypeFormattingProvider[]; } -export class Workspace { +export class Workspace { private readonly localizer: Localizer; - private readonly diagnosticProviders: Map; - private readonly onTypeFormattingProviders: Map; + private readonly diagnosticProviders: Map>; + private readonly onTypeFormattingProviders: Map>; private readonly lastDiagnosticChangedEvents = new Map(); public readonly diagnosticsChanged$: Observable; - constructor(config: WorkspaceConfig) { + constructor(config: WorkspaceConfig) { this.localizer = config.localizer; this.diagnosticProviders = new Map(config.diagnosticProviders?.map((provider) => [provider.id, provider])); this.diagnosticsChanged$ = merge( @@ -57,7 +57,7 @@ export class Workspace { return diagnostics; } - async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise { + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { const provider = this.diagnosticProviders.get(diagnostic.source); if (provider == null) { return []; @@ -75,7 +75,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..1e55cc4 --- /dev/null +++ b/packages/delta/eslint.config.js @@ -0,0 +1,17 @@ +import library from '@repo/eslint-config/library.js'; + +export default [ + { + languageOptions: { + parserOptions: { + project: './tsconfig.json', + }, + }, + }, + ...library, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]; 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..6e45fed --- /dev/null +++ b/packages/delta/src/delta-document-factory.ts @@ -0,0 +1,15 @@ +import { DocumentFactory } 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: string): DeltaDocument { + return new DeltaDocument(uri, format, version, new Delta(JSON.parse(content))); + } + + update(document: DeltaDocument, changes: readonly Op[], version: number): DeltaDocument { + document.update(changes, version); + return document; + } +} diff --git a/packages/delta/src/delta-document.test.ts b/packages/delta/src/delta-document.test.ts new file mode 100644 index 0000000..b1ae6c1 --- /dev/null +++ b/packages/delta/src/delta-document.test.ts @@ -0,0 +1,75 @@ +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { DeltaDocument } from './delta-document'; + +describe('DeltaDocument', () => { + it('getText', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.getText()).toEqual('Hello\n\nWorld\uFFFC\n!\n'); + }); + + it('update', () => { + const document = new DeltaDocument('uri', 'rich-text', 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('Hello everybody!\n'); + }); + + it('offsetAt', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 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: 0, character: 5 })).toEqual(5); + 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); + }); + + it('positionAt', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(document.positionAt(5)).toEqual({ line: 0, character: 5 }); + expect(document.positionAt(6)).toEqual({ line: 1, character: 0 }); + expect(document.positionAt(7)).toEqual({ line: 2, character: 0 }); + expect(document.positionAt(13)).toEqual({ line: 2, character: 6 }); + expect(document.positionAt(14)).toEqual({ line: 3, character: 0 }); + }); +}); diff --git a/packages/delta/src/delta-document.ts b/packages/delta/src/delta-document.ts new file mode 100644 index 0000000..80b882b --- /dev/null +++ b/packages/delta/src/delta-document.ts @@ -0,0 +1,108 @@ +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 readonly format: 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); + } + + 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/src/delta-edit-factory.ts b/packages/delta/src/delta-edit-factory.ts new file mode 100644 index 0000000..72c4210 --- /dev/null +++ b/packages/delta/src/delta-edit-factory.ts @@ -0,0 +1,20 @@ +import { EditFactory } from '@sillsdev/lynx'; +import { Op } from 'quill-delta'; + +import { DeltaDocument } from './delta-document'; + +export class DeltaEditFactory implements EditFactory { + createTextEdit(_document: DeltaDocument, 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; + } +} diff --git a/packages/delta/src/delta-scripture-document.test.ts b/packages/delta/src/delta-scripture-document.test.ts new file mode 100644 index 0000000..c0d16a3 --- /dev/null +++ b/packages/delta/src/delta-scripture-document.test.ts @@ -0,0 +1,36 @@ +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { DeltaScriptureDocument } from './delta-scripture-document'; + +describe('DeltaDocument', () => { + it('constructor', () => { + const document = new DeltaScriptureDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('Chapter 1, verse 1. ', { segment: 'verse_1_1' }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('Chapter 1, verse 2.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ chapter: { number: '2', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('Chapter 2, verse 1. ', { segment: 'verse_2_1' }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('Chapter 2, verse 2.', { segment: 'verse_2_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + expect(document.getText()).toEqual('Hello\n\nWorld\uFFFC\n!\n'); + }); + + // it('update', () => { + // const document = new DeltaScriptureDocument('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('Hello everybody!\n'); + // }); +}); diff --git a/packages/delta/src/delta-scripture-document.ts b/packages/delta/src/delta-scripture-document.ts new file mode 100644 index 0000000..c7b76ac --- /dev/null +++ b/packages/delta/src/delta-scripture-document.ts @@ -0,0 +1,85 @@ +import { + Position, + Range, + ScriptureChapter, + ScriptureDocument, + ScriptureNode, + ScriptureParagraph, + ScriptureText, + ScriptureVerse, +} from '@sillsdev/lynx'; +import Delta from 'quill-delta'; + +import { DeltaDocument } from './delta-document'; + +export class DeltaScriptureDocument extends ScriptureDocument { + private readonly deltaDoc: DeltaDocument; + + constructor(uri: string, version: number, content: Delta) { + super(uri); + this.deltaDoc = new DeltaDocument(uri, 'scripture-delta', version, content); + this.parseDelta(content); + } + + get version(): number { + return this.deltaDoc.version; + } + + set version(version: number) { + this.deltaDoc.version = version; + } + + get format(): string { + return this.deltaDoc.format; + } + + get content(): Delta { + return this.deltaDoc.content; + } + + getText(range?: Range): string { + return this.deltaDoc.getText(range); + } + + offsetAt(position: Position): number { + return this.deltaDoc.offsetAt(position); + } + + positionAt(offset: number, range?: Range): Position { + return this.deltaDoc.positionAt(offset, range); + } + + private parseDelta(content: Delta): void { + this.clearChildren(); + content.eachLine((line, attributes) => { + const children: ScriptureNode[] = []; + line.forEach((op) => { + if (typeof op.insert === 'string') { + children.push(new ScriptureText(op.insert)); + } else if (op.insert != null) { + // embeds + for (const [key, value] of Object.entries(op.insert)) { + const attrs = value as any; + switch (key) { + case 'chapter': + this.appendChild(new ScriptureChapter(attrs.number)); + break; + + case 'verse': + children.push(new ScriptureVerse(attrs.number)); + break; + } + } + } + }); + for (const [key, value] of Object.entries(attributes)) { + const attrs = value as any; + switch (key) { + case 'para': + this.appendChild(new ScriptureParagraph(attrs.style, children)); + break; + } + } + }); + } +} diff --git a/packages/delta/src/index.ts b/packages/delta/src/index.ts new file mode 100644 index 0000000..1af14c2 --- /dev/null +++ b/packages/delta/src/index.ts @@ -0,0 +1,3 @@ +export { DeltaDocument } from './delta-document'; +export { DeltaDocumentFactory } from './delta-document-factory'; +export { DeltaEditFactory } from './delta-edit-factory'; 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..f9aa177 100644 --- a/packages/examples/src/simple-quote-formatting-provider.ts +++ b/packages/examples/src/simple-quote-formatting-provider.ts @@ -1,30 +1,31 @@ -import { DocumentManager, OnTypeFormattingProvider, Position, TextDocument, TextEdit } from '@sillsdev/lynx'; +import { Document, DocumentAccessor, EditFactory, OnTypeFormattingProvider, Position, TextEdit } from '@sillsdev/lynx'; -export class SimpleQuoteFormattingProvider implements OnTypeFormattingProvider { +export class SimpleQuoteFormattingProvider implements OnTypeFormattingProvider { readonly id = 'simple-quote'; readonly onTypeTriggerCharacters: ReadonlySet = new Set(['"', '“', '”']); - constructor(private readonly documentManager: DocumentManager) {} + constructor( + private readonly documents: DocumentAccessor, + private readonly editFactory: EditFactory, + ) {} init(): Promise { return Promise.resolve(); } - async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { - const doc = await this.documentManager.get(uri); + async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { + const doc = await this.documents.get(uri); if (doc == null) { return undefined; } - const edits: TextEdit[] = []; + const edits: T[] = []; 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(...this.editFactory.createTextEdit(doc, 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(...this.editFactory.createTextEdit(doc, 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..6473b74 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, + ScriptureEditFactory, + 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 editFactory: ScriptureEditFactory; readonly docManager: DocumentManager; readonly provider: VerseOrderDiagnosticProvider; constructor() { this.localizer = new Localizer(); + this.editFactory = mock(); this.docManager = new DocumentManager(mock>()); - this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager, mock()); + this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager, this.editFactory); } 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, '', nodes)); + } } diff --git a/packages/examples/src/verse-order-diagnostic-provider.ts b/packages/examples/src/verse-order-diagnostic-provider.ts index 28e518c..eb51198 100644 --- a/packages/examples/src/verse-order-diagnostic-provider.ts +++ b/packages/examples/src/verse-order-diagnostic-provider.ts @@ -4,43 +4,44 @@ import { DiagnosticProvider, DiagnosticsChanged, DiagnosticSeverity, - DocumentManager, + DocumentAccessor, Localizer, ScriptureChapter, ScriptureDocument, + ScriptureEditFactory, ScriptureNodeType, - ScriptureSerializer, ScriptureVerse, + TextEdit, } from '@sillsdev/lynx'; import { map, merge, Observable, switchMap } from 'rxjs'; -export class VerseOrderDiagnosticProvider implements DiagnosticProvider { +export class VerseOrderDiagnosticProvider implements DiagnosticProvider { public readonly id = 'verse-order'; public readonly diagnosticsChanged$: Observable; constructor( private readonly localizer: Localizer, - private readonly documentManager: DocumentManager, - private readonly serializer: ScriptureSerializer, + private readonly documents: DocumentAccessor, + private readonly editFactory: ScriptureEditFactory, ) { this.diagnosticsChanged$ = merge( - documentManager.opened$.pipe( + documents.opened$.pipe( map((e) => ({ uri: e.document.uri, version: e.document.version, diagnostics: this.validateDocument(e.document), })), ), - documentManager.changed$.pipe( + documents.changed$.pipe( map((e) => ({ uri: e.document.uri, version: e.document.version, diagnostics: this.validateDocument(e.document), })), ), - documentManager.closed$.pipe( + documents.closed$.pipe( switchMap(async (e) => { - const doc = await this.documentManager.get(e.uri); + const doc = await this.documents.get(e.uri); return { uri: e.uri, version: doc?.version, diagnostics: [] }; }), ), @@ -56,30 +57,30 @@ export class VerseOrderDiagnosticProvider implements DiagnosticProvider { } async getDiagnostics(uri: string): Promise { - const doc = await this.documentManager.get(uri); + const doc = await this.documents.get(uri); if (doc == null) { return []; } return this.validateDocument(doc); } - getDiagnosticFixes(_uri: string, diagnostic: Diagnostic): Promise { - const fixes: DiagnosticFix[] = []; + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { + const doc = await this.documents.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: this.editFactory.createScriptureEdit(doc, offset, offset, new ScriptureVerse(verseNumber.toString())), }); } - return Promise.resolve(fixes); + return fixes; } private validateDocument(doc: ScriptureDocument): Diagnostic[] { diff --git a/packages/usfm/src/index.ts b/packages/usfm/src/index.ts index eb04c16..dc73997 100644 --- a/packages/usfm/src/index.ts +++ b/packages/usfm/src/index.ts @@ -1,3 +1,4 @@ export { UsfmDocument } from './usfm-document'; export { UsfmDocumentFactory } from './usfm-document-factory'; +export { UsfmEditFactory } from './usfm-edit-factory'; export { UsfmScriptureSerializer } from './usfm-scripture-serializer'; diff --git a/packages/usfm/src/usfm-document-factory.ts b/packages/usfm/src/usfm-document-factory.ts index 5526a00..c9fc073 100644 --- a/packages/usfm/src/usfm-document-factory.ts +++ b/packages/usfm/src/usfm-document-factory.ts @@ -1,23 +1,20 @@ -import { DocumentChange, DocumentFactory, ScriptureDocument } from '@sillsdev/lynx'; +import { DocumentFactory, TextDocumentChange } from '@sillsdev/lynx'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { UsfmDocument } from './usfm-document'; -export class UsfmDocumentFactory implements DocumentFactory { +export class UsfmDocumentFactory implements DocumentFactory { constructor(private readonly styleSheet: UsfmStylesheet) {} - 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); } - update(document: ScriptureDocument, changes: readonly DocumentChange[], version: number): ScriptureDocument { - if (document instanceof UsfmDocument) { - document.update(changes, version); - return document; - } - throw new Error('The document must be created by this factory.'); + update(document: UsfmDocument, changes: readonly TextDocumentChange[], version: number): UsfmDocument { + document.update(changes, version); + return document; } } diff --git a/packages/usfm/src/usfm-document.ts b/packages/usfm/src/usfm-document.ts index 2e1bf06..67a7404 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, @@ -18,6 +16,8 @@ import { ScriptureTable, ScriptureText, ScriptureVerse, + TextDocumentChange, + TextScriptureDocument, } from '@sillsdev/lynx'; import { UsfmAttribute, @@ -29,7 +29,7 @@ import { UsfmTokenType, } from '@sillsdev/machine/corpora'; -export class UsfmDocument extends ScriptureDocument { +export class UsfmDocument extends TextScriptureDocument { private lineChildren: number[] = []; constructor( @@ -39,11 +39,11 @@ export class UsfmDocument extends ScriptureDocument { private readonly stylesheet: UsfmStylesheet, start: Position = { line: 0, character: 0 }, ) { - super(uri, version, content); + super(uri, 'usfm', version, content); 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); @@ -106,7 +106,7 @@ export class UsfmDocument extends ScriptureDocument { } } } - this.updateContent(change); + this.text.updateContent(change); } this.version = version; } diff --git a/packages/usfm/src/usfm-edit-factory.ts b/packages/usfm/src/usfm-edit-factory.ts new file mode 100644 index 0000000..d41b560 --- /dev/null +++ b/packages/usfm/src/usfm-edit-factory.ts @@ -0,0 +1,23 @@ +import { ScriptureNode, TextEdit, TextScriptureEditFactory } from '@sillsdev/lynx'; +import { UsfmStylesheet } from '@sillsdev/machine'; + +import { UsfmDocument } from './usfm-document'; +import { UsfmScriptureSerializer } from './usfm-scripture-serializer'; + +export class UsfmEditFactory extends TextScriptureEditFactory { + private readonly serializer: UsfmScriptureSerializer; + + constructor(stylesheet: UsfmStylesheet) { + super(); + this.serializer = new UsfmScriptureSerializer(stylesheet); + } + + createScriptureEdit( + document: UsfmDocument, + startOffset: number, + endOffset: number, + nodes: ScriptureNode[] | ScriptureNode, + ): TextEdit[] { + return this.createTextEdit(document, startOffset, endOffset, this.serializer.serialize(nodes)); + } +} 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/usfm/src/usfm-scripture-serializer.ts b/packages/usfm/src/usfm-scripture-serializer.ts index 07d064e..9839c5a 100644 --- a/packages/usfm/src/usfm-scripture-serializer.ts +++ b/packages/usfm/src/usfm-scripture-serializer.ts @@ -11,7 +11,6 @@ import { ScriptureParagraph, ScriptureRef, ScriptureRow, - ScriptureSerializer, ScriptureSidebar, ScriptureTable, ScriptureText, @@ -19,7 +18,7 @@ import { } from '@sillsdev/lynx'; import { UsfmAttribute, UsfmStylesheet, UsfmToken, UsfmTokenizer, UsfmTokenType } from '@sillsdev/machine/corpora'; -export class UsfmScriptureSerializer implements ScriptureSerializer { +export class UsfmScriptureSerializer { private readonly tokenizer: UsfmTokenizer; constructor(private readonly stylesheet: UsfmStylesheet) { diff --git a/packages/vscode/src/server.ts b/packages/vscode/src/server.ts index d085c20..425cc8b 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, UsfmEditFactory } from '@sillsdev/lynx-usfm'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { CodeAction, @@ -21,12 +21,12 @@ 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 editFactory = new UsfmEditFactory(stylesheet); const documentManager = new DocumentManager(documentFactory); const workspace = new Workspace({ localizer, - diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager, scriptureSerializer)], - onTypeFormattingProviders: [new SimpleQuoteFormattingProvider(documentManager)], + diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager, editFactory)], + onTypeFormattingProviders: [new SimpleQuoteFormattingProvider(documentManager, editFactory)], }); let hasWorkspaceFolderCapability = false;