diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts index 4810a54..789b0f8 100644 --- a/server/src/analyzer.ts +++ b/server/src/analyzer.ts @@ -47,9 +47,9 @@ import * as fsSync from 'node:fs'; import * as url from 'node:url'; import { ModelicaDocument, ModelicaLibrary, ModelicaProject } from './project'; -import { uriToPath } from "./util"; import { getAllDeclarationsInTree } from './util/declarations'; import { logger } from './util/logger'; +import { uriToPath } from './util'; export default class Analyzer { #project: ModelicaProject; @@ -98,8 +98,8 @@ export default class Analyzer { * @param uri uri to document to add * @throws if the document does not belong to a library */ - public addDocument(uri: LSP.DocumentUri): void { - this.#project.addDocument(uriToPath(uri)); + public async addDocument(uri: LSP.DocumentUri): Promise { + await this.#project.addDocument(uriToPath(uri)); } /** @@ -110,8 +110,8 @@ export default class Analyzer { * @param text the modification * @param range range to update, or `undefined` to replace the whole file */ - public async updateDocument(uri: LSP.DocumentUri, text: string): Promise { - await this.#project.updateDocument(uriToPath(uri), text); + public async updateDocument(uri: LSP.DocumentUri, text: string, range?: LSP.Range): Promise { + await this.#project.updateDocument(uriToPath(uri), text, range); } /** diff --git a/server/src/project/document.ts b/server/src/project/document.ts index ac17f42..ea7e26c 100644 --- a/server/src/project/document.ts +++ b/server/src/project/document.ts @@ -39,10 +39,11 @@ import Parser from 'web-tree-sitter'; import * as fs from 'node:fs/promises'; import * as TreeSitterUtil from '../util/tree-sitter'; -import { pathToUri, uriToPath } from '../util'; import { logger } from '../util/logger'; import { ModelicaLibrary } from './library'; import { ModelicaProject } from './project'; +import { positionToPoint } from '../util/tree-sitter'; +import { pathToUri, uriToPath } from '../util'; export class ModelicaDocument implements TextDocument { readonly #project: ModelicaProject; @@ -50,7 +51,12 @@ export class ModelicaDocument implements TextDocument { readonly #document: TextDocument; #tree: Parser.Tree; - public constructor(project: ModelicaProject, library: ModelicaLibrary | null, document: TextDocument, tree: Parser.Tree) { + public constructor( + project: ModelicaProject, + library: ModelicaLibrary | null, + document: TextDocument, + tree: Parser.Tree, + ) { this.#project = project; this.#library = library; this.#document = document; @@ -90,12 +96,54 @@ export class ModelicaDocument implements TextDocument { /** * Updates a document. + * * @param text the modification + * @param range the range to update, or `undefined` to replace the whole file */ - public async update(text: string): Promise { - TextDocument.update(this.#document, [{ text }], this.version + 1); - this.#tree = this.project.parser.parse(text); - return; + public async update(text: string, range?: LSP.Range): Promise { + if (range === undefined) { + TextDocument.update(this.#document, [{ text }], this.version + 1); + this.#tree = this.project.parser.parse(text); + return; + } + + const startIndex = this.offsetAt(range.start); + const startPosition = positionToPoint(range.start); + const oldEndIndex = this.offsetAt(range.end); + const oldEndPosition = positionToPoint(range.end); + const newEndIndex = startIndex + text.length; + + TextDocument.update(this.#document, [{ text, range }], this.version + 1); + const newEndPosition = positionToPoint(this.positionAt(newEndIndex)); + + this.#tree.edit({ + startIndex, + startPosition, + oldEndIndex, + oldEndPosition, + newEndIndex, + newEndPosition, + }); + + this.#tree = this.project.parser.parse((index: number, position?: Parser.Point) => { + if (position) { + return this.getText({ + start: { + character: position.column, + line: position.row, + }, + end: { + character: position.column + 1, + line: position.row, + }, + }); + } else { + return this.getText({ + start: this.positionAt(index), + end: this.positionAt(index + 1), + }); + } + }, this.#tree); } public getText(range?: LSP.Range | undefined): string { @@ -138,7 +186,7 @@ export class ModelicaDocument implements TextDocument { public get within(): string[] { const withinClause = this.#tree.rootNode.children .find((node) => node.type === 'within_clause') - ?.childForFieldName("name"); + ?.childForFieldName('name'); if (!withinClause) { return []; } @@ -146,11 +194,11 @@ export class ModelicaDocument implements TextDocument { // TODO: Use a helper function from TreeSitterUtil const identifiers: string[] = []; TreeSitterUtil.forEach(withinClause, (node) => { - if (node.type === "name") { + if (node.type === 'name') { return true; } - if (node.type === "IDENT") { + if (node.type === 'IDENT') { identifiers.push(node.text); } diff --git a/server/src/project/project.ts b/server/src/project/project.ts index 8c06622..53adec4 100644 --- a/server/src/project/project.ts +++ b/server/src/project/project.ts @@ -33,14 +33,14 @@ * */ -import Parser from "web-tree-sitter"; -import * as LSP from "vscode-languageserver"; -import url from "node:url"; -import path from "node:path"; +import Parser from 'web-tree-sitter'; +import * as LSP from 'vscode-languageserver'; +import url from 'node:url'; +import path from 'node:path'; -import { ModelicaLibrary } from "./library"; +import { ModelicaLibrary } from './library'; import { ModelicaDocument } from './document'; -import { logger } from "../util/logger"; +import { logger } from '../util/logger'; /** Options for {@link ModelicaProject.getDocument} */ export interface GetDocumentOptions { @@ -117,7 +117,7 @@ export class ModelicaProject { for (const library of this.#libraries) { const relative = path.relative(library.path, documentPath); - const isSubdirectory = relative && !relative.startsWith("..") && !path.isAbsolute(relative); + const isSubdirectory = relative && !relative.startsWith('..') && !path.isAbsolute(relative); // Assume that files can't be inside multiple libraries at the same time if (!isSubdirectory) { @@ -155,12 +155,12 @@ export class ModelicaProject { * @param text the modification * @returns if the document was updated */ - public async updateDocument(documentPath: string, text: string): Promise { + public async updateDocument(documentPath: string, text: string, range?: LSP.Range): Promise { logger.debug(`Updating document at '${documentPath}'...`); const doc = await this.getDocument(documentPath, { load: true }); if (doc) { - doc.update(text); + doc.update(text, range); logger.debug(`Updated document '${documentPath}'`); return true; } else { @@ -191,5 +191,4 @@ export class ModelicaProject { public get parser(): Parser { return this.#parser; } - } diff --git a/server/src/project/test/document.test.ts b/server/src/project/test/document.test.ts index 09f7431..760d777 100644 --- a/server/src/project/test/document.test.ts +++ b/server/src/project/test/document.test.ts @@ -82,6 +82,46 @@ describe('ModelicaDocument', () => { assert.equal(document.getText().trim(), UPDATED_TEST_PACKAGE_CONTENT.trim()); }); + it('can update incrementally', () => { + const textDocument = createTextDocument('.', TEST_PACKAGE_CONTENT); + const tree = project.parser.parse(TEST_PACKAGE_CONTENT); + const document = new ModelicaDocument(project, library, textDocument, tree); + document.update( + '1.0.1', + { + start: { + line: 1, + character: 22, + }, + end: { + line: 1, + character: 27, + }, + } + ); + + assert.equal(document.getText().trim(), UPDATED_TEST_PACKAGE_CONTENT.trim()); + + document.update( + '\n model A\n end A;', + { + start: { + line: 1, + character: 30, + }, + end: { + line: 1, + character: 30, + }, + } + ); + + const model = document.tree.rootNode.descendantsOfType("class_definition")[1]; + assert.equal(model.type, "class_definition"); + assert.equal(model.descendantsOfType("IDENT")[0].text, "A"); + assert.equal(document.tree.rootNode.descendantsOfType("annotation_clause").length, 1); + }); + it('a file with no `within` clause has the correct package path', () => { const textDocument = createTextDocument('./package.mo', TEST_PACKAGE_CONTENT); const tree = project.parser.parse(TEST_PACKAGE_CONTENT); diff --git a/server/src/server.ts b/server/src/server.ts index a4ec4ea..5ac20d0 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -102,7 +102,7 @@ export class ModelicaServer { documentSymbolProvider: true, colorProvider: false, semanticTokensProvider: undefined, - textDocumentSync: LSP.TextDocumentSyncKind.Full, + textDocumentSync: LSP.TextDocumentSyncKind.Incremental, workspace: { workspaceFolders: { supported: true, @@ -150,7 +150,8 @@ export class ModelicaServer { private async onDidChangeTextDocument(params: LSP.DidChangeTextDocumentParams): Promise { logger.debug('onDidChangeTextDocument'); for (const change of params.contentChanges) { - await this.#analyzer.updateDocument(params.textDocument.uri, change.text); + const range = 'range' in change ? change.range : undefined; + await this.#analyzer.updateDocument(params.textDocument.uri, change.text, range); } } @@ -160,7 +161,7 @@ export class ModelicaServer { for (const change of params.changes) { switch (change.type) { case LSP.FileChangeType.Created: - this.#analyzer.addDocument(change.uri); + await this.#analyzer.addDocument(change.uri); break; case LSP.FileChangeType.Changed: { // TODO: incremental? diff --git a/server/src/util/tree-sitter.ts b/server/src/util/tree-sitter.ts index f159ce5..601f632 100644 --- a/server/src/util/tree-sitter.ts +++ b/server/src/util/tree-sitter.ts @@ -39,6 +39,7 @@ * ----------------------------------------------------------------------------- */ +import Parser from 'web-tree-sitter'; import * as LSP from 'vscode-languageserver/node'; import { SyntaxNode } from 'web-tree-sitter'; @@ -169,3 +170,11 @@ export function getClassPrefixes(node: SyntaxNode): string | null { return classPrefixNode.text; } + +export function positionToPoint(position: LSP.Position): Parser.Point { + return { row: position.line, column: position.character }; +} + +export function pointToPosition(point: Parser.Point): LSP.Position { + return { line: point.row, character: point.column }; +}