Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import org.eclipse.lsp4j.SaveOptions;
import org.eclipse.lsp4j.SelectionRangeRegistrationOptions;
import org.eclipse.lsp4j.SemanticTokensLegend;
import org.eclipse.lsp4j.SemanticTokensServerFull;
import org.eclipse.lsp4j.SemanticTokensWithRegistrationOptions;
import org.eclipse.lsp4j.ServerCapabilities;
import org.eclipse.lsp4j.ServerInfo;
Expand Down Expand Up @@ -379,7 +380,11 @@ private ExecuteCommandOptions getExecuteCommandProvider() {

private SemanticTokensWithRegistrationOptions getSemanticTokensProvider() {
var semanticTokensProvider = new SemanticTokensWithRegistrationOptions(legend);
semanticTokensProvider.setFull(Boolean.TRUE);

var fullOptions = new SemanticTokensServerFull();
fullOptions.setDelta(Boolean.TRUE);
semanticTokensProvider.setFull(fullOptions);

semanticTokensProvider.setRange(Boolean.FALSE);
return semanticTokensProvider;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
import org.eclipse.lsp4j.SelectionRange;
import org.eclipse.lsp4j.SelectionRangeParams;
import org.eclipse.lsp4j.SemanticTokens;
import org.eclipse.lsp4j.SemanticTokensDelta;
import org.eclipse.lsp4j.SemanticTokensDeltaParams;
import org.eclipse.lsp4j.SemanticTokensParams;
import org.eclipse.lsp4j.SymbolInformation;
import org.eclipse.lsp4j.TextDocumentClientCapabilities;
Expand Down Expand Up @@ -341,7 +343,20 @@ public CompletableFuture<SemanticTokens> semanticTokensFull(SemanticTokensParams
);
}

@Override
public CompletableFuture<Either<SemanticTokens, SemanticTokensDelta>> semanticTokensFullDelta(
SemanticTokensDeltaParams params
) {
var documentContext = context.getDocument(params.getTextDocument().getUri());
if (documentContext == null) {
return CompletableFuture.completedFuture(null);
}

return withFreshDocumentContext(
documentContext,
() -> semanticTokensProvider.getSemanticTokensFullDelta(documentContext, params)
);
}

@Override
public CompletableFuture<List<CallHierarchyIncomingCall>> callHierarchyIncomingCalls(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.ServerContext;
import com.github._1c_syntax.bsl.languageserver.context.events.DocumentContextContentChangedEvent;
import com.github._1c_syntax.bsl.languageserver.context.events.ServerContextDocumentClosedEvent;
import com.github._1c_syntax.bsl.languageserver.context.events.ServerContextDocumentRemovedEvent;
import com.github._1c_syntax.bsl.languageserver.context.events.ServerContextPopulatedEvent;
import com.github._1c_syntax.bsl.languageserver.events.LanguageServerInitializeRequestReceivedEvent;
Expand Down Expand Up @@ -91,6 +92,11 @@ public void serverContextRemoveDocument(JoinPoint joinPoint, URI uri) {
publishEvent(new ServerContextDocumentRemovedEvent((ServerContext) joinPoint.getThis(), uri));
}

@AfterReturning("Pointcuts.isServerContext() && Pointcuts.isCloseDocumentCall() && args(documentContext)")
public void serverContextCloseDocument(JoinPoint joinPoint, DocumentContext documentContext) {
publishEvent(new ServerContextDocumentClosedEvent((ServerContext) joinPoint.getThis(), documentContext));
}

@AfterReturning("Pointcuts.isLanguageServer() && Pointcuts.isInitializeCall() && args(initializeParams)")
public void languageServerInitialize(JoinPoint joinPoint, InitializeParams initializeParams) {
var event = new LanguageServerInitializeRequestReceivedEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ public void isRemoveDocumentCall() {
// no-op
}

/**
* Это вызов метода closeDocument.
*/
@Pointcut("isBSLLanguageServerScope() && execution(* closeDocument(..))")
public void isCloseDocumentCall() {
// no-op
}

/**
* Это вызов метода update.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* This file is a part of BSL Language Server.
*
* Copyright (c) 2018-2025
* Alexey Sosnoviy <[email protected]>, Nikita Fedkin <[email protected]> and contributors
*
* SPDX-License-Identifier: LGPL-3.0-or-later
*
* BSL Language Server is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3.0 of the License, or (at your option) any later version.
*
* BSL Language Server is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with BSL Language Server.
*/
package com.github._1c_syntax.bsl.languageserver.context.events;

import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.ServerContext;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;

import java.io.Serial;

/**
* Событие, публикуемое при закрытии документа в контексте сервера.
* <p>
* Событие генерируется контекстом сервера {@link ServerContext} при вызове метода
* {@link ServerContext#closeDocument(DocumentContext)} и содержит закрытый документ.
* <p>
* Подписчики на это событие могут выполнить очистку связанных с документом данных,
* таких как кэшированные семантические токены и другие временные данные.
* <p>
* Событие публикуется после того, как документ был помечен как закрытый.
*
* @see ServerContext#closeDocument(DocumentContext)
*/
public class ServerContextDocumentClosedEvent extends ApplicationEvent {

@Serial
private static final long serialVersionUID = 8274629847264754220L;

/**
* Закрытый документ.
*/
@Getter
private final DocumentContext documentContext;

/**
* Создает новое событие закрытия документа в контексте сервера.
*
* @param source контекст сервера, в котором был закрыт документ
* @param documentContext закрытый документ
*/
public ServerContextDocumentClosedEvent(ServerContext source, DocumentContext documentContext) {
super(source);
this.documentContext = documentContext;
}

@Override
public ServerContext getSource() {
return (ServerContext) super.getSource();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,41 +22,72 @@
package com.github._1c_syntax.bsl.languageserver.providers;

import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.events.ServerContextDocumentClosedEvent;
import com.github._1c_syntax.bsl.languageserver.context.events.ServerContextDocumentRemovedEvent;
import com.github._1c_syntax.bsl.languageserver.semantictokens.SemanticTokenEntry;
import com.github._1c_syntax.bsl.languageserver.semantictokens.SemanticTokensSupplier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.lsp4j.SemanticTokens;
import org.eclipse.lsp4j.SemanticTokensDelta;
import org.eclipse.lsp4j.SemanticTokensDeltaParams;
import org.eclipse.lsp4j.SemanticTokensEdit;
import org.eclipse.lsp4j.SemanticTokensParams;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* Провайдер для предоставления семантических токенов.
* <p>
* Обрабатывает запросы {@code textDocument/semanticTokens/full}.
* Обрабатывает запросы {@code textDocument/semanticTokens/full} и {@code textDocument/semanticTokens/full/delta}.
*
* @see <a href="https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_semanticTokens">Semantic Tokens specification</a>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SemanticTokensProvider {

private final List<SemanticTokensSupplier> suppliers;

/**
* Cache for storing previous token data by resultId.
* Key: resultId, Value: token data list
*/
private final Map<String, CachedTokenData> tokenCache = new ConcurrentHashMap<>();

/**
* Cached semantic token data associated with a document.
*
* @param uri URI of the document
* @param data token data list
*/
private record CachedTokenData(URI uri, List<Integer> data) {
}

/**
* Получить семантические токены для всего документа.
*
* @param documentContext Контекст документа
* @param params Параметры запроса
* @return Семантические токены в дельта-кодированном формате
*/
public SemanticTokens getSemanticTokensFull(DocumentContext documentContext, @SuppressWarnings("unused") SemanticTokensParams params) {
public SemanticTokens getSemanticTokensFull(
DocumentContext documentContext,
@SuppressWarnings("unused") SemanticTokensParams params
) {
// Collect tokens from all suppliers
List<SemanticTokenEntry> entries = suppliers.stream()
.map(supplier -> supplier.getSemanticTokens(documentContext))
Expand All @@ -65,7 +96,159 @@ public SemanticTokens getSemanticTokensFull(DocumentContext documentContext, @Su

// Build delta-encoded data
List<Integer> data = toDeltaEncoded(entries);
return new SemanticTokens(data);

// Generate a unique resultId and cache the data
String resultId = generateResultId();
cacheTokenData(resultId, documentContext.getUri(), data);

return new SemanticTokens(resultId, data);
}

/**
* Получить дельту семантических токенов относительно предыдущего результата.
*
* @param documentContext Контекст документа
* @param params Параметры запроса с previousResultId
* @return Либо дельту токенов, либо полные токены, если предыдущий результат недоступен
*/
public Either<SemanticTokens, SemanticTokensDelta> getSemanticTokensFullDelta(
DocumentContext documentContext,
SemanticTokensDeltaParams params
) {
String previousResultId = params.getPreviousResultId();
CachedTokenData previousData = tokenCache.get(previousResultId);

// Calculate current tokens
List<SemanticTokenEntry> entries = suppliers.stream()
.map(supplier -> supplier.getSemanticTokens(documentContext))
.flatMap(Collection::stream)
.toList();

List<Integer> currentData = toDeltaEncoded(entries);

// Generate new resultId
String resultId = generateResultId();

// If previous data is not available or belongs to a different document, return full tokens
if (previousData == null || !previousData.uri().equals(documentContext.getUri())) {
LOGGER.debug("Returning full tokens: previousData={}, uri match={}",
previousData != null, previousData != null && previousData.uri().equals(documentContext.getUri()));
cacheTokenData(resultId, documentContext.getUri(), currentData);
return Either.forLeft(new SemanticTokens(resultId, currentData));
}

// Compute delta edits
List<SemanticTokensEdit> edits = computeEdits(previousData.data(), currentData);

// Log delta statistics for debugging
if (!edits.isEmpty()) {
var edit = edits.get(0);
LOGGER.debug("Delta computed: previousSize={}, currentSize={}, start={}, deleteCount={}, dataSize={}",
previousData.data().size(), currentData.size(),
edit.getStart(), edit.getDeleteCount(),
edit.getData() != null ? edit.getData().size() : 0);
}

// Cache the new data
cacheTokenData(resultId, documentContext.getUri(), currentData);

// Remove the old cached data
tokenCache.remove(previousResultId);

var delta = new SemanticTokensDelta();
delta.setResultId(resultId);
delta.setEdits(edits);
return Either.forRight(delta);
}

/**
* Обрабатывает событие закрытия документа в контексте сервера.
* <p>
* При закрытии документа очищает кэшированные данные семантических токенов.
*
* @param event событие закрытия документа
*/
@EventListener
public void handleDocumentClosed(ServerContextDocumentClosedEvent event) {
clearCache(event.getDocumentContext().getUri());
}

/**
* Обрабатывает событие удаления документа из контекста сервера.
* <p>
* При удалении документа очищает кэшированные данные семантических токенов.
*
* @param event событие удаления документа
*/
@EventListener
public void handleDocumentRemoved(ServerContextDocumentRemovedEvent event) {
clearCache(event.getUri());
}

/**
* Очищает кэшированные данные токенов для указанного документа.
*
* @param uri URI документа, для которого нужно очистить кэш
*/
protected void clearCache(URI uri) {
tokenCache.entrySet().removeIf(entry -> entry.getValue().uri().equals(uri));
}

/**
* Generate a unique result ID for caching.
*/
private static String generateResultId() {
return UUID.randomUUID().toString();
}

/**
* Cache token data with the given resultId.
*/
private void cacheTokenData(String resultId, URI uri, List<Integer> data) {
tokenCache.put(resultId, new CachedTokenData(uri, data));
}

/**
* Compute edits to transform previousData into currentData.
* Uses a simple algorithm that produces a single edit covering the entire change.
*/
private static List<SemanticTokensEdit> computeEdits(List<Integer> previousData, List<Integer> currentData) {
// Find the first differing index
int minSize = Math.min(previousData.size(), currentData.size());
int prefixMatch = 0;
while (prefixMatch < minSize && previousData.get(prefixMatch).equals(currentData.get(prefixMatch))) {
prefixMatch++;
}

// If both are identical, return empty edits
if (prefixMatch == previousData.size() && prefixMatch == currentData.size()) {
return List.of();
}

// Find the last differing index (from the end)
int suffixMatch = 0;
while (suffixMatch < minSize - prefixMatch
&& previousData.get(previousData.size() - 1 - suffixMatch)
.equals(currentData.get(currentData.size() - 1 - suffixMatch))) {
suffixMatch++;
}

// Calculate the range to replace
int deleteStart = prefixMatch;
int deleteCount = previousData.size() - prefixMatch - suffixMatch;
int insertEnd = currentData.size() - suffixMatch;

// Extract the data to insert
List<Integer> insertData = currentData.subList(prefixMatch, insertEnd);

var edit = new SemanticTokensEdit();
edit.setStart(deleteStart);
edit.setDeleteCount(deleteCount);
if (!insertData.isEmpty()) {
edit.setData(new ArrayList<>(insertData));
}

return List.of(edit);
}

private static List<Integer> toDeltaEncoded(List<SemanticTokenEntry> entries) {
Expand Down
Loading