From 2f63b93b2a80fcb2a7eb6a535f70d7e813e739ed Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Thu, 31 Oct 2024 21:57:52 -0500 Subject: [PATCH] Add on type formatting provider --- .vscode/launch.json | 3 +- packages/core/package.json | 2 +- packages/core/src/common/index.ts | 1 + packages/core/src/common/text-edit.ts | 6 + .../src/diagnostic/diagnostic-provider.ts | 7 +- packages/core/src/document/index.ts | 6 +- .../core/src/document/scripture-container.ts | 46 ++++- .../core/src/document/scripture-document.ts | 155 +++++----------- packages/core/src/document/scripture-leaf.ts | 71 +++++++ .../core/src/document/scripture-milestone.ts | 5 +- packages/core/src/document/scripture-node.ts | 93 ++-------- .../core/src/document/scripture-optbreak.ts | 9 +- .../core/src/document/scripture-paragraph.ts | 6 +- packages/core/src/document/scripture-ref.ts | 5 +- packages/core/src/document/scripture-text.ts | 5 +- .../src/document/text-document-factory.ts | 13 ++ packages/core/src/document/text-document.ts | 173 ++++++++++++++++++ packages/core/src/formatting/index.ts | 1 + .../formatting/on-type-formatting-provider.ts | 19 ++ packages/core/src/index.ts | 1 + packages/core/src/workspace/workspace.ts | 64 ++++++- packages/eslint-config/library.mjs | 2 + packages/usfm/package.json | 2 +- packages/usfm/src/usfm-document.ts | 23 +-- packages/vscode/package.json | 2 +- packages/vscode/src/server.ts | 17 +- .../src/smart-quote-formatting-provider.ts | 32 ++++ 27 files changed, 544 insertions(+), 225 deletions(-) create mode 100644 packages/core/src/common/text-edit.ts create mode 100644 packages/core/src/document/scripture-leaf.ts create mode 100644 packages/core/src/document/text-document-factory.ts create mode 100644 packages/core/src/document/text-document.ts create mode 100644 packages/core/src/formatting/index.ts create mode 100644 packages/core/src/formatting/on-type-formatting-provider.ts create mode 100644 packages/vscode/src/smart-quote-formatting-provider.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index cb04145..b6c3eff 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,8 @@ "${workspaceRoot}/packages/usfm/dist/**/*.cjs" ], "autoAttachChildProcesses": true, - "preLaunchTask": "npm: dev" + "preLaunchTask": "npm: dev", + "sourceMaps": true } ] } diff --git a/packages/core/package.json b/packages/core/package.json index 4ce553c..c02ee9d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsup src/index.ts --dts --format cjs,esm --clean --sourcemap", - "dev": "tsup src/index.ts --dts --format cjs,esm --watch", + "dev": "tsup src/index.ts --dts --format cjs,esm --watch --sourcemap", "check-types": "tsc --noEmit", "lint": "eslint .", "test": "vitest", diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index f1c63e3..40a8145 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -1,2 +1,3 @@ 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 new file mode 100644 index 0000000..4415f76 --- /dev/null +++ b/packages/core/src/common/text-edit.ts @@ -0,0 +1,6 @@ +import { Range } from './range'; + +export interface TextEdit { + range: Range; + newText: string; +} diff --git a/packages/core/src/diagnostic/diagnostic-provider.ts b/packages/core/src/diagnostic/diagnostic-provider.ts index 5fa851c..01998e8 100644 --- a/packages/core/src/diagnostic/diagnostic-provider.ts +++ b/packages/core/src/diagnostic/diagnostic-provider.ts @@ -5,7 +5,12 @@ import { DocumentManager } from '../document/document-manager'; import { Diagnostic } from './diagnostic'; import { DiagnosticFix } from './diagnostic-fix'; -export type DiagnosticProviderFactory = (DocumentManager: DocumentManager) => DiagnosticProvider; +export type DiagnosticProviderFactory = ( + DocumentManager: DocumentManager, +) => DiagnosticProvider; +export type DiagnosticProviderConstructor = new ( + documentManager: DocumentManager, +) => DiagnosticProvider; export interface DiagnosticsChanged { uri: string; diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts index dd54b5c..4cab0fe 100644 --- a/packages/core/src/document/index.ts +++ b/packages/core/src/document/index.ts @@ -8,8 +8,10 @@ export { ScriptureChapter } from './scripture-chapter'; export { ScriptureCharacterStyle } from './scripture-character-style'; export { ScriptureContainer } from './scripture-container'; export { ScriptureDocument } from './scripture-document'; +export { ScriptureLeaf } from './scripture-leaf'; export { ScriptureMilestone } from './scripture-milestone'; -export { ScriptureNode, ScriptureNodeType } from './scripture-node'; +export type { ScriptureNode } from './scripture-node'; +export { ScriptureNodeType } from './scripture-node'; export { ScriptureNote } from './scripture-note'; export { ScriptureOptBreak } from './scripture-optbreak'; export { ScriptureParagraph } from './scripture-paragraph'; @@ -19,3 +21,5 @@ 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 { TextDocumentFactory } from './text-document-factory'; diff --git a/packages/core/src/document/scripture-container.ts b/packages/core/src/document/scripture-container.ts index 3057844..7a2fa28 100644 --- a/packages/core/src/document/scripture-container.ts +++ b/packages/core/src/document/scripture-container.ts @@ -1,10 +1,15 @@ +import { Position } from '../common/position'; +import { Range } from '../common/range'; +import { ScriptureDocument } from './scripture-document'; import { ScriptureNode, ScriptureNodeType } from './scripture-node'; -export abstract class ScriptureContainer extends ScriptureNode { +export abstract class ScriptureContainer implements ScriptureNode { + private _parent?: ScriptureNode; private readonly _children: ScriptureNode[] = []; + readonly isLeaf = false; + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; constructor(children?: ScriptureNode[]) { - super(); if (children != null) { for (const child of children) { this.appendChild(child); @@ -12,10 +17,38 @@ export abstract class ScriptureContainer extends ScriptureNode { } } + abstract readonly type: ScriptureNodeType; + + get document(): ScriptureDocument | undefined { + return this._parent?.document; + } + + get parent(): ScriptureNode | undefined { + return this._parent; + } + get children(): readonly ScriptureNode[] { return this._children; } + updateParent(parent: ScriptureNode | undefined): void { + this._parent = parent; + } + + remove(): void { + if (this._parent == null) { + throw new Error('The node does not have a parent.'); + } + this._parent.removeChild(this); + } + + getText(): string { + if (this.document == null) { + throw new Error('The node is not part of a document.'); + } + return this.document.getText(this.range); + } + *getNodes(filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean)): IterableIterator { for (const child of this._children) { if (filter == null || child.type === filter || (typeof filter === 'function' && filter(child))) { @@ -25,6 +58,13 @@ export abstract class ScriptureContainer extends ScriptureNode { } } + positionAt(offset: number): Position { + if (this.document == null) { + throw new Error('The node is not part of a document.'); + } + return this.document.positionAt(offset, this.range); + } + appendChild(child: ScriptureNode): void { this._children.push(child); child.updateParent(this); @@ -57,7 +97,7 @@ export abstract class ScriptureContainer extends ScriptureNode { } } - clear(): void { + clearChildren(): void { this._children.length = 0; } } diff --git a/packages/core/src/document/scripture-document.ts b/packages/core/src/document/scripture-document.ts index 2215f41..c5518bb 100644 --- a/packages/core/src/document/scripture-document.ts +++ b/packages/core/src/document/scripture-document.ts @@ -1,14 +1,15 @@ -import { Position } from '../common/position'; -import { Range } from '../common/range'; +import { Range } from '../common'; import { Document } from './document'; -import { DocumentChange } from './document-factory'; -import { ScriptureContainer } from './scripture-container'; import { ScriptureNode, ScriptureNodeType } from './scripture-node'; +import { TextDocument } from './text-document'; -export class ScriptureDocument extends ScriptureContainer implements Document { - private _lineOffsets: number[] | undefined = undefined; - private _content: string; - private _version: number; +export class ScriptureDocument extends TextDocument implements Document, ScriptureNode { + private readonly _children: ScriptureNode[] = []; + readonly parent: undefined = undefined; + readonly isLeaf = false; + readonly type = ScriptureNodeType.Document; + readonly document = this; + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; constructor( public readonly uri: string, @@ -16,124 +17,68 @@ export class ScriptureDocument extends ScriptureContainer implements Document { content: string, children?: ScriptureNode[], ) { - super(children); - this._version = version; - this._content = content; + super(uri, version, content); + if (children != null) { + for (const child of children) { + this.appendChild(child); + } + } } - get type(): ScriptureNodeType { - return ScriptureNodeType.Document; + get children(): readonly ScriptureNode[] { + return this._children; } - get document(): this { - return this; + updateParent(_parent: ScriptureNode | undefined): void { + throw new Error('The method is not supported.'); } - get content(): string { - return this._content; + remove(): void { + throw new Error('The method is not supported.'); } - get version(): number { - return this._version; + *getNodes(filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean)): IterableIterator { + for (const child of this._children) { + if (filter == null || child.type === filter || (typeof filter === 'function' && filter(child))) { + yield child; + } + yield* child.getNodes(filter); + } } - protected set version(value: number) { - this._version = value; + appendChild(child: ScriptureNode): void { + this._children.push(child); + child.updateParent(this); } - getText(range?: Range): string { - if (range != null) { - const start = this.offsetAt(range.start); - const end = this.offsetAt(range.end); - return this._content.substring(start, end); - } - return this._content; + insertChild(index: number, child: ScriptureNode): void { + this._children.splice(index, 0, child); + child.updateParent(this); } - offsetAt(position: Position): number { - const lineOffsets = this.getLineOffsets(); - if (position.line >= lineOffsets.length) { - return this._content.length; - } else if (position.line < 0) { - return 0; + removeChild(child: ScriptureNode): void { + if (child.parent !== this) { + throw new Error('This node does not contain the specified child.'); } - const lineOffset = lineOffsets[position.line]; - if (position.character <= 0) { - return lineOffset; + const index = this._children.indexOf(child); + if (index === -1) { + throw new Error('This node does not contain the specified child.'); } - - const nextLineOffset = - position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : this._content.length; - const offset = Math.min(lineOffset + position.character, nextLineOffset); - return this.ensureBeforeEndOfLine(offset, lineOffset); + this._children.splice(index, 1); + child.updateParent(undefined); } - protected updateContent(change: DocumentChange): void { - if (change.range == null) { - this._content = change.text; - this._lineOffsets = undefined; - } else { - const range = change.range; - const startOffset = this.offsetAt(range.start); - const endOffset = this.offsetAt(range.end); - this._content = - this._content.substring(0, startOffset) + - change.text + - this._content.substring(endOffset, this._content.length); - - // update the offsets - const startLine = Math.max(range.start.line, 0); - const endLine = Math.max(range.end.line, 0); - let lineOffsets = this._lineOffsets!; - const addedLineOffsets = computeLineOffsets(change.text, false, startOffset); - if (endLine - startLine === addedLineOffsets.length) { - for (let i = 0, len = addedLineOffsets.length; i < len; i++) { - lineOffsets[i + startLine + 1] = addedLineOffsets[i]; - } - } else { - if (addedLineOffsets.length < 10000) { - lineOffsets.splice(startLine + 1, endLine - startLine, ...addedLineOffsets); - } else { - // avoid too many arguments for splice - this._lineOffsets = lineOffsets = lineOffsets - .slice(0, startLine + 1) - .concat(addedLineOffsets, lineOffsets.slice(endLine + 1)); - } - } - const diff = change.text.length - (endOffset - startOffset); - if (diff !== 0) { - for (let i = startLine + 1 + addedLineOffsets.length, len = lineOffsets.length; i < len; i++) { - lineOffsets[i] = lineOffsets[i] + diff; - } - } + spliceChildren(start: number, deleteCount: number, ...items: ScriptureNode[]): void { + const removed = this._children.splice(start, deleteCount, ...items); + for (const child of removed) { + child.updateParent(undefined); } - } - - public getLineOffsets(): number[] { - if (this._lineOffsets === undefined) { - this._lineOffsets = computeLineOffsets(this._content, true); - } - return this._lineOffsets; - } - - public ensureBeforeEndOfLine(offset: number, lineOffset: number): number { - while (offset > lineOffset && (this._content[offset - 1] === '\r' || this._content[offset - 1] === '\n')) { - offset--; + for (const child of items) { + child.updateParent(this); } - return offset; } -} -function computeLineOffsets(text: string, isAtLineStart: boolean, textOffset = 0): number[] { - const result: number[] = isAtLineStart ? [textOffset] : []; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (ch === '\n' || ch === '\r') { - if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') { - i++; - } - result.push(textOffset + i + 1); - } + clearChildren(): void { + this._children.length = 0; } - return result; } diff --git a/packages/core/src/document/scripture-leaf.ts b/packages/core/src/document/scripture-leaf.ts new file mode 100644 index 0000000..bafe29b --- /dev/null +++ b/packages/core/src/document/scripture-leaf.ts @@ -0,0 +1,71 @@ +import { Position } from '../common/position'; +import { Range } from '../common/range'; +import { ScriptureDocument } from './scripture-document'; +import { ScriptureNode, ScriptureNodeType } from './scripture-node'; + +export abstract class ScriptureLeaf implements ScriptureNode { + private _parent?: ScriptureNode; + readonly children: readonly ScriptureNode[] = []; + readonly isLeaf = true; + + constructor(public range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }) {} + + abstract readonly type: ScriptureNodeType; + + get document(): ScriptureDocument | undefined { + return this._parent?.document; + } + + get parent(): ScriptureNode | undefined { + return this._parent; + } + + updateParent(parent: ScriptureNode | undefined): void { + this._parent = parent; + } + + remove(): void { + if (this._parent == null) { + throw new Error('The node does not have a parent.'); + } + this._parent.removeChild(this); + } + + getText(): string { + if (this.document == null) { + throw new Error('The node is not part of a document.'); + } + return this.document.getText(this.range); + } + + *getNodes(_filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean)): IterableIterator { + // return nothing + } + + positionAt(offset: number): Position { + if (this.document == null) { + throw new Error('The node is not part of a document.'); + } + return this.document.positionAt(offset, this.range); + } + + appendChild(_child: ScriptureNode): void { + throw new Error('The method not supported.'); + } + + insertChild(_index: number, _child: ScriptureNode): void { + throw new Error('The method not supported.'); + } + + removeChild(_child: ScriptureNode): void { + throw new Error('The method not supported.'); + } + + spliceChildren(_start: number, _deleteCount: number, ..._items: ScriptureNode[]): void { + throw new Error('The method not supported.'); + } + + clearChildren(): void { + throw new Error('The method not supported.'); + } +} diff --git a/packages/core/src/document/scripture-milestone.ts b/packages/core/src/document/scripture-milestone.ts index bff0f9d..9699482 100644 --- a/packages/core/src/document/scripture-milestone.ts +++ b/packages/core/src/document/scripture-milestone.ts @@ -1,7 +1,8 @@ import { Range } from '../common/range'; -import { ScriptureNode, ScriptureNodeType } from './scripture-node'; +import { ScriptureLeaf } from './scripture-leaf'; +import { ScriptureNodeType } from './scripture-node'; -export class ScriptureMilestone extends ScriptureNode { +export class ScriptureMilestone extends ScriptureLeaf { constructor( public readonly style: string, public readonly sid?: string, diff --git a/packages/core/src/document/scripture-node.ts b/packages/core/src/document/scripture-node.ts index 21e2c4b..971a618 100644 --- a/packages/core/src/document/scripture-node.ts +++ b/packages/core/src/document/scripture-node.ts @@ -1,6 +1,5 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; -import { ScriptureContainer } from './scripture-container'; import { ScriptureDocument } from './scripture-document'; export enum ScriptureNodeType { @@ -21,78 +20,22 @@ export enum ScriptureNodeType { Sidebar, } -export abstract class ScriptureNode { - private _parent?: ScriptureContainer; - - constructor(public range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }) {} - - abstract readonly type: ScriptureNodeType; - - get document(): ScriptureDocument | undefined { - return this._parent?.document; - } - - get parent(): ScriptureContainer | undefined { - return this._parent; - } - - updateParent(parent: ScriptureContainer | undefined): void { - this._parent = parent; - } - - remove(): void { - if (this._parent == null) { - throw new Error('The node does not have a parent.'); - } - this._parent.removeChild(this); - } - - getText(): string { - if (this.document == null) { - throw new Error('The node is not part of a document.'); - } - return this.document.getText(this.range); - } - - *getNodes(_filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean)): IterableIterator { - // return nothing - } - - positionAt(offset: number): Position { - if (this.document == null) { - throw new Error('The node is not part of a document.'); - } - if (this.range.start.line === this.range.end.line) { - return { - line: this.range.start.line, - character: Math.min(this.range.start.character + offset, this.range.end.character), - }; - } - - const startOffset = this.document.offsetAt(this.range.start); - const endOffset = this.document.offsetAt(this.range.end); - if (startOffset === endOffset) { - return this.range.start; - } - let contentOffset = startOffset + offset; - contentOffset = Math.max(Math.min(contentOffset, endOffset), 0); - - const lineOffsets = this.document.getLineOffsets(); - let low = 0; - let high = this.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; - - contentOffset = this.document.ensureBeforeEndOfLine(contentOffset, lineOffsets[line]); - return { line, character: contentOffset - lineOffsets[line] }; - } +export interface ScriptureNode { + readonly type: ScriptureNodeType; + readonly document: ScriptureDocument | undefined; + readonly parent: ScriptureNode | undefined; + readonly children: readonly ScriptureNode[]; + range: Range; + readonly isLeaf: boolean; + + updateParent(parent: ScriptureNode | undefined): void; + remove(): void; + getText(): string; + getNodes(filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean)): IterableIterator; + positionAt(offset: number): Position; + appendChild(child: ScriptureNode): void; + insertChild(index: number, child: ScriptureNode): void; + removeChild(child: ScriptureNode): void; + spliceChildren(start: number, deleteCount: number, ...items: ScriptureNode[]): void; + clearChildren(): void; } diff --git a/packages/core/src/document/scripture-optbreak.ts b/packages/core/src/document/scripture-optbreak.ts index 01a248b..bb15880 100644 --- a/packages/core/src/document/scripture-optbreak.ts +++ b/packages/core/src/document/scripture-optbreak.ts @@ -1,7 +1,6 @@ -import { ScriptureNode, ScriptureNodeType } from './scripture-node'; +import { ScriptureLeaf } from './scripture-leaf'; +import { ScriptureNodeType } from './scripture-node'; -export class ScriptureOptBreak extends ScriptureNode { - get type(): ScriptureNodeType { - return ScriptureNodeType.OptBreak; - } +export class ScriptureOptBreak extends ScriptureLeaf { + readonly type = ScriptureNodeType.OptBreak; } diff --git a/packages/core/src/document/scripture-paragraph.ts b/packages/core/src/document/scripture-paragraph.ts index c1f74f9..b59a44a 100644 --- a/packages/core/src/document/scripture-paragraph.ts +++ b/packages/core/src/document/scripture-paragraph.ts @@ -2,14 +2,12 @@ import { ScriptureContainer } from './scripture-container'; import { ScriptureNodeType } from './scripture-node'; export class ScriptureParagraph extends ScriptureContainer { + readonly type = ScriptureNodeType.Paragraph; + constructor( public readonly style: string, public readonly attributes: Record = {}, ) { super(); } - - get type(): ScriptureNodeType { - return ScriptureNodeType.Paragraph; - } } diff --git a/packages/core/src/document/scripture-ref.ts b/packages/core/src/document/scripture-ref.ts index 5b05a78..ca68ccd 100644 --- a/packages/core/src/document/scripture-ref.ts +++ b/packages/core/src/document/scripture-ref.ts @@ -1,7 +1,8 @@ import { Range } from '../common/range'; -import { ScriptureNode, ScriptureNodeType } from './scripture-node'; +import { ScriptureLeaf } from './scripture-leaf'; +import { ScriptureNodeType } from './scripture-node'; -export class ScriptureRef extends ScriptureNode { +export class ScriptureRef extends ScriptureLeaf { constructor( public readonly display: string, public readonly target: string, diff --git a/packages/core/src/document/scripture-text.ts b/packages/core/src/document/scripture-text.ts index c44ea5c..07684ec 100644 --- a/packages/core/src/document/scripture-text.ts +++ b/packages/core/src/document/scripture-text.ts @@ -1,7 +1,8 @@ import { Range } from '../common/range'; -import { ScriptureNode, ScriptureNodeType } from './scripture-node'; +import { ScriptureLeaf } from './scripture-leaf'; +import { ScriptureNodeType } from './scripture-node'; -export class ScriptureText extends ScriptureNode { +export class ScriptureText extends ScriptureLeaf { constructor( public readonly text: string, range?: Range, diff --git a/packages/core/src/document/text-document-factory.ts b/packages/core/src/document/text-document-factory.ts new file mode 100644 index 0000000..cf46fc8 --- /dev/null +++ b/packages/core/src/document/text-document-factory.ts @@ -0,0 +1,13 @@ +import { DocumentChange, DocumentFactory } from './document-factory'; +import { TextDocument } from './text-document'; + +export class TextDocumentFactory implements DocumentFactory { + create(uri: string, _format: string, version: number, content: string): TextDocument { + return new TextDocument(uri, version, content); + } + + update(document: TextDocument, changes: readonly DocumentChange[], 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 new file mode 100644 index 0000000..d1269c5 --- /dev/null +++ b/packages/core/src/document/text-document.ts @@ -0,0 +1,173 @@ +import { Position } from '../common/position'; +import { Range } from '../common/range'; +import { Document } from './document'; +import { DocumentChange } from './document-factory'; + +export class TextDocument implements Document { + private _lineOffsets: number[] | undefined = undefined; + private _content: string; + private _version: number; + + constructor( + public readonly uri: string, + version: number, + content: string, + ) { + this._version = version; + this._content = content; + } + + get content(): string { + 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); + const end = this.offsetAt(range.end); + return this._content.substring(start, end); + } + return this._content; + } + + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + if (position.line >= lineOffsets.length) { + return this._content.length; + } 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] : this._content.length; + const offset = Math.min(lineOffset + position.character, nextLineOffset); + return this.ensureBeforeEndOfLine(offset, lineOffset); + } + + 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; + + contentOffset = this.ensureBeforeEndOfLine(contentOffset, lineOffsets[line]); + return { line, character: contentOffset - lineOffsets[line] }; + } + + update(changes: readonly DocumentChange[], version: number): void { + for (const change of changes) { + this.updateContent(change); + } + this.version = version; + } + + protected updateContent(change: DocumentChange): void { + if (change.range == null) { + this._content = change.text; + this._lineOffsets = undefined; + } else { + const range = change.range; + const startOffset = this.offsetAt(range.start); + const endOffset = this.offsetAt(range.end); + this._content = + this._content.substring(0, startOffset) + + change.text + + this._content.substring(endOffset, this._content.length); + + // update the offsets + const startLine = Math.max(range.start.line, 0); + const endLine = Math.max(range.end.line, 0); + let lineOffsets = this._lineOffsets!; + const addedLineOffsets = computeLineOffsets(change.text, false, startOffset); + if (endLine - startLine === addedLineOffsets.length) { + for (let i = 0, len = addedLineOffsets.length; i < len; i++) { + lineOffsets[i + startLine + 1] = addedLineOffsets[i]; + } + } else { + if (addedLineOffsets.length < 10000) { + lineOffsets.splice(startLine + 1, endLine - startLine, ...addedLineOffsets); + } else { + // avoid too many arguments for splice + this._lineOffsets = lineOffsets = lineOffsets + .slice(0, startLine + 1) + .concat(addedLineOffsets, lineOffsets.slice(endLine + 1)); + } + } + const diff = change.text.length - (endOffset - startOffset); + if (diff !== 0) { + for (let i = startLine + 1 + addedLineOffsets.length, len = lineOffsets.length; i < len; i++) { + lineOffsets[i] = lineOffsets[i] + diff; + } + } + } + } + + public getLineOffsets(): number[] { + if (this._lineOffsets === undefined) { + this._lineOffsets = computeLineOffsets(this._content, true); + } + return this._lineOffsets; + } + + public ensureBeforeEndOfLine(offset: number, lineOffset: number): number { + while (offset > lineOffset && (this._content[offset - 1] === '\r' || this._content[offset - 1] === '\n')) { + offset--; + } + return offset; + } +} + +function computeLineOffsets(text: string, isAtLineStart: boolean, textOffset = 0): number[] { + const result: number[] = isAtLineStart ? [textOffset] : []; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') { + i++; + } + result.push(textOffset + i + 1); + } + } + return result; +} diff --git a/packages/core/src/formatting/index.ts b/packages/core/src/formatting/index.ts new file mode 100644 index 0000000..d5b9370 --- /dev/null +++ b/packages/core/src/formatting/index.ts @@ -0,0 +1 @@ +export type { OnTypeFormattingProvider, OnTypeFormattingProviderFactory } from './on-type-formatting-provider'; diff --git a/packages/core/src/formatting/on-type-formatting-provider.ts b/packages/core/src/formatting/on-type-formatting-provider.ts new file mode 100644 index 0000000..34d7497 --- /dev/null +++ b/packages/core/src/formatting/on-type-formatting-provider.ts @@ -0,0 +1,19 @@ +import { Position } from '../common/position'; +import { TextEdit } from '../common/text-edit'; +import { Document } from '../document/document'; +import { DocumentManager } from '../document/document-manager'; + +export type OnTypeFormattingProviderFactory = ( + DocumentManager: DocumentManager, +) => OnTypeFormattingProvider; +export type OnTypeFormattingProviderConstructor = new ( + documentManager: DocumentManager, +) => OnTypeFormattingProvider; + +export interface OnTypeFormattingProvider { + readonly id: string; + + readonly onTypeTriggerCharacters: ReadonlySet; + + getOnTypeEdits(uri: string, position: Position, ch: string): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 58b433b..93ddc26 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './common'; export * from './diagnostic'; export * from './document'; +export * from './formatting'; export * from './workspace'; diff --git a/packages/core/src/workspace/workspace.ts b/packages/core/src/workspace/workspace.ts index 0836773..a4f2645 100644 --- a/packages/core/src/workspace/workspace.ts +++ b/packages/core/src/workspace/workspace.ts @@ -1,21 +1,35 @@ 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, DiagnosticProviderFactory, DiagnosticsChanged } from '../diagnostic/diagnostic-provider'; +import { + DiagnosticProvider, + DiagnosticProviderConstructor, + DiagnosticProviderFactory, + DiagnosticsChanged, +} from '../diagnostic/diagnostic-provider'; import { Document } from '../document/document'; import { DocumentFactory } from '../document/document-factory'; import { DocumentManager } from '../document/document-manager'; import { DocumentReader } from '../document/document-reader'; +import { + OnTypeFormattingProvider, + OnTypeFormattingProviderConstructor, + OnTypeFormattingProviderFactory, +} from '../formatting/on-type-formatting-provider'; -export interface WorkspaceConfig { +export interface WorkspaceConfig { documentReader?: DocumentReader; documentFactory: DocumentFactory; - diagnosticProviders: DiagnosticProviderFactory[]; + diagnosticProviders?: (DiagnosticProviderFactory | DiagnosticProviderConstructor)[]; + onTypeFormattingProviders?: (OnTypeFormattingProviderFactory | OnTypeFormattingProviderConstructor)[]; } -export class Workspace { +export class Workspace { private readonly diagnosticProviders: Map; + private readonly onTypeFormattingProviders: Map; private readonly lastDiagnosticChangedEvents = new Map(); public readonly documentManager: DocumentManager; @@ -24,8 +38,13 @@ export class Workspace { constructor(config: WorkspaceConfig) { this.documentManager = new DocumentManager(config.documentReader, config.documentFactory); this.diagnosticProviders = new Map( - config.diagnosticProviders.map((factory) => { - const provider = factory(this.documentManager); + config.diagnosticProviders?.map((factory) => { + let provider: DiagnosticProvider; + try { + provider = new (factory as DiagnosticProviderConstructor)(this.documentManager); + } catch { + provider = (factory as DiagnosticProviderFactory)(this.documentManager); + } return [provider.id, provider]; }), ); @@ -38,6 +57,17 @@ export class Workspace { ), ), ).pipe(map((e) => this.getCombinedDiagnosticChangedEvent(e.uri, e.version))); + this.onTypeFormattingProviders = new Map( + config.onTypeFormattingProviders?.map((factory) => { + let provider: OnTypeFormattingProvider; + try { + provider = new (factory as OnTypeFormattingProviderConstructor)(this.documentManager); + } catch { + provider = (factory as OnTypeFormattingProviderFactory)(this.documentManager); + } + return [provider.id, provider]; + }), + ); } async getDiagnostics(uri: string): Promise { @@ -56,6 +86,28 @@ export class Workspace { return await provider.getDiagnosticFixes(uri, diagnostic); } + getOnTypeTriggerCharacters(): string[] { + const characters = new Set(); + for (const provider of this.onTypeFormattingProviders.values()) { + for (const ch of provider.onTypeTriggerCharacters) { + characters.add(ch); + } + } + return Array.from(characters); + } + + 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); + if (edits != null) { + return edits; + } + } + } + return undefined; + } + private updateCombinedDiagnosticChangedEvent(providerIndex: number, event: DiagnosticsChanged) { const docEvents = this.lastDiagnosticChangedEvents.get(event.uri) ?? []; docEvents[providerIndex] = event; diff --git a/packages/eslint-config/library.mjs b/packages/eslint-config/library.mjs index 9b2a7dd..81bb2d6 100644 --- a/packages/eslint-config/library.mjs +++ b/packages/eslint-config/library.mjs @@ -32,6 +32,8 @@ export default tseslint.config( ], '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-return': 'off', }, }, { diff --git a/packages/usfm/package.json b/packages/usfm/package.json index cbe0d11..c231bca 100644 --- a/packages/usfm/package.json +++ b/packages/usfm/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsup src/index.ts --dts --format cjs,esm --clean --sourcemap", - "dev": "tsup src/index.ts --dts --format cjs,esm --watch", + "dev": "tsup src/index.ts --dts --format cjs,esm --watch --sourcemap", "check-types": "tsc --noEmit", "lint": "eslint .", "test": "vitest", diff --git a/packages/usfm/src/usfm-document.ts b/packages/usfm/src/usfm-document.ts index f15b63a..fe13117 100644 --- a/packages/usfm/src/usfm-document.ts +++ b/packages/usfm/src/usfm-document.ts @@ -15,7 +15,6 @@ import { ScriptureCell, ScriptureChapter, ScriptureCharacterStyle, - ScriptureContainer, ScriptureDocument, ScriptureMilestone, ScriptureNode, @@ -112,13 +111,13 @@ export class UsfmDocument extends ScriptureDocument { this.version = version; } - clear(): void { - super.clear(); + clearChildren(): void { + super.clearChildren(); this.lineChildren.length = 0; } private parseUsfm(content: string, start: Position = { line: 0, character: 0 }): void { - this.clear(); + this.clearChildren(); const handler = new UsfmDocumentBuilder(this); const tokenizer = new UsfmTokenizer(this.stylesheet); const tokens = tokenizer.tokenize(content, false, start.line + 1, start.character + 1); @@ -139,15 +138,13 @@ export class UsfmDocument extends ScriptureDocument { function updateNodeLine(node: ScriptureNode, lineDiff: number): void { node.range.start.line += lineDiff; node.range.end.line += lineDiff; - if (node instanceof ScriptureContainer) { - for (const child of node.children) { - updateNodeLine(child, lineDiff); - } + for (const child of node.children) { + updateNodeLine(child, lineDiff); } } class UsfmDocumentBuilder extends UsfmParserHandlerBase { - private readonly containerStack: ScriptureContainer[] = []; + private readonly containerStack: ScriptureNode[] = []; constructor(public readonly document: UsfmDocument) { super(); @@ -309,7 +306,7 @@ class UsfmDocumentBuilder extends UsfmParserHandlerBase { ); } - private startContainer(state: UsfmParserState, containerNode: ScriptureContainer): void { + private startContainer(state: UsfmParserState, containerNode: ScriptureNode): void { containerNode.range.start = { line: state.lineNumber - 1, character: state.columnNumber - 1 }; this.peek()?.appendChild(containerNode); this.push(containerNode); @@ -337,18 +334,18 @@ class UsfmDocumentBuilder extends UsfmParserHandlerBase { } } - private push(container: ScriptureContainer): void { + private push(container: ScriptureNode): void { this.containerStack.push(container); } - private peek(): ScriptureContainer | undefined { + private peek(): ScriptureNode | undefined { if (this.containerStack.length === 0) { return undefined; } return this.containerStack[this.containerStack.length - 1]; } - private pop(): ScriptureContainer | undefined { + private pop(): ScriptureNode | undefined { return this.containerStack.pop(); } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index e2479f4..4a74fc6 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -4,7 +4,7 @@ "main": "./dist/extension.js", "scripts": { "build": "tsup-node src/extension.ts src/server.ts --format cjs --clean --sourcemap", - "dev": "tsup-node src/extension.ts src/server.ts --format cjs --watch", + "dev": "tsup-node src/extension.ts src/server.ts --format cjs --watch --sourcemap", "check-types": "tsc --noEmit", "lint": "eslint ." }, diff --git a/packages/vscode/src/server.ts b/packages/vscode/src/server.ts index d85821f..d7c2575 100644 --- a/packages/vscode/src/server.ts +++ b/packages/vscode/src/server.ts @@ -1,5 +1,5 @@ import { UsfmStylesheet } from '@sillsdev/machine/corpora'; -import { Diagnostic, ScriptureDocument, Workspace } from 'lynx-core'; +import { Diagnostic, Workspace } from 'lynx-core'; import { UsfmDocumentFactory } from 'lynx-usfm'; import { CodeAction, @@ -13,6 +13,7 @@ import { TextDocumentSyncKind, } from 'vscode-languageserver/node'; +import { SmartQuoteFormattingProvider } from './smart-quote-formatting-provider'; import { TestDiagnosticProvider, TestDiagnosticProviderConfig } from './test-diagnostic-provider'; // The global settings, used when the `workspace/configuration` request is not supported by the client. @@ -26,9 +27,10 @@ let globalSettings: TestDiagnosticProviderConfig = defaultSettings; const connection = createConnection(ProposedFeatures.all); // Create a simple text document manager. -const workspace = new Workspace({ +const workspace = new Workspace({ documentFactory: new UsfmDocumentFactory(new UsfmStylesheet('usfm.sty')), diagnosticProviders: [TestDiagnosticProvider.factory(() => globalSettings)], + onTypeFormattingProviders: [SmartQuoteFormattingProvider], }); let hasConfigurationCapability = false; @@ -52,6 +54,13 @@ connection.onInitialize((params: InitializeParams) => { codeActionProvider: true, }, }; + const onTypeTriggerCharacters = workspace.getOnTypeTriggerCharacters(); + if (onTypeTriggerCharacters.length > 0) { + result.capabilities.documentOnTypeFormattingProvider = { + firstTriggerCharacter: onTypeTriggerCharacters[0], + moreTriggerCharacter: onTypeTriggerCharacters.slice(1), + }; + } if (hasWorkspaceFolderCapability) { result.capabilities.workspace = { workspaceFolders: { @@ -130,6 +139,10 @@ connection.onDidChangeTextDocument((params) => { ); }); +connection.onDocumentOnTypeFormatting(async (params) => { + return await workspace.getOnTypeEdits(params.textDocument.uri, params.position, params.ch); +}); + connection.onDidChangeWatchedFiles((_change) => { // Monitored files have change in VSCode connection.console.log('We received a file change event'); diff --git a/packages/vscode/src/smart-quote-formatting-provider.ts b/packages/vscode/src/smart-quote-formatting-provider.ts new file mode 100644 index 0000000..6799b59 --- /dev/null +++ b/packages/vscode/src/smart-quote-formatting-provider.ts @@ -0,0 +1,32 @@ +import { DocumentManager, OnTypeFormattingProvider, Position, TextDocument, TextEdit } from 'lynx-core'; + +export class SmartQuoteFormattingProvider implements OnTypeFormattingProvider { + readonly id = 'smart-quote'; + readonly onTypeTriggerCharacters: ReadonlySet = new Set(['"', '“', '”']); + + constructor(private readonly documentManager: DocumentManager) {} + + 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 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: '“' }); + } 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: '”' }); + } + } + + if (edits.length === 0) { + return undefined; + } + return edits; + } +}