diff --git a/.gitignore b/.gitignore index 6f6cda472a0..3a53ab8f834 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,5 @@ bsl-language-server_*.zip *.log *.hprof /.idea/material_theme_project_new.xml -__pycache__/ +.bsl-ls-cache/ +__pycache__/ diff --git a/build.gradle.kts b/build.gradle.kts index 948a1c912d0..d8fd28cedca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { // кэширование api("com.github.ben-manes.caffeine", "caffeine", "3.2.0") + api("org.ehcache:ehcache:3.10.8") // lsp4j core api("org.eclipse.lsp4j", "org.eclipse.lsp4j", "0.24.0") @@ -208,6 +209,14 @@ tasks.test { val jmockitPath = classpath.find { it.name.contains("jmockit") }!!.absolutePath jvmArgs("-javaagent:${jmockitPath}") + + // Cleanup test cache directories after tests complete + doLast { + val tmpDir = File(System.getProperty("java.io.tmpdir")) + tmpDir.listFiles()?.filter { it.name.startsWith("bsl-ls-cache-") }?.forEach { cacheDir -> + cacheDir.deleteRecursively() + } + } } tasks.check { diff --git a/docs/diagnostics/Typo.md b/docs/diagnostics/Typo.md index 77ee09d8e80..d20f1e36471 100644 --- a/docs/diagnostics/Typo.md +++ b/docs/diagnostics/Typo.md @@ -6,6 +6,44 @@ Проверка орфографических ошибок осуществляется с помощью [LanguageTool](https://languagetool.org/ru/). Проверяемые строки разбиваются по camelCase и проверяются на соответствие во встроенном словаре. +## Кэш +Диагностика использует персистентный кэш на диске (EhCache) для хранения информации об уже проверенных словах. Путь к каталогу кэша задаётся свойством `app.cache.path` в конфигурации приложения. + +По умолчанию в приложении установлено: + +```properties +app.cache.path=.bsl-ls-cache +``` + +Это означает, что кэш будет создаваться в каталоге `.bsl-ls-cache` относительно рабочей директории приложения (можно переопределить через переменные окружения или аргументы командной строки). + +Рекомендации для CI: кэшируйте или переносите этот каталог между сборками, чтобы избежать повторной персистентной инвалидации и ускорить проверки. Ниже — примеры подходов для популярных CI-платформ. + +- GitHub Actions + - Используйте `actions/cache` для сохранения каталога (`.bsl-ls-cache` или путь, заданный в `app.cache.path`) между прогоном сборок и тестов. + +- GitLab CI + - В `.gitlab-ci.yml` используйте секцию `cache`: + + ```yaml + cache: + key: "bsl-ls-typo-cache" + paths: + - .bsl-ls-cache/ + policy: pull-push + ``` + + - При необходимости задайте уникальный `key` для разных веток/раннеров. + +- Jenkins + - В pipeline можно сохранить каталог кэша между сборками несколькими способами: + - Использовать `stash`/`unstash` для передачи данных между этапами в одной сборке. + - Использовать плагин `Workspace Cleanup` и настроить сохранение workspace на агенте (если агенты постоянные) или архивировать артефакт с помощью `archiveArtifacts` и скачивать при следующих сборках. + - Для Jenkins при использовании динамических агентов (например, Kubernetes) рекомендуется сохранять кэш в сетевом хранилище или в объектном хранилище (S3) и восстанавливать его в начале job. + +Общие рекомендации: +- Убедитесь, что путь, указанный в `app.cache.path`, доступен процессу сборки и имеет достаточные права. + ## Источники diff --git a/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/TypoDiagnostic.java b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/TypoDiagnostic.java index e0903842509..052a30fdfe3 100644 --- a/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/TypoDiagnostic.java +++ b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/TypoDiagnostic.java @@ -27,13 +27,16 @@ import com.github._1c_syntax.bsl.languageserver.diagnostics.metadata.DiagnosticSeverity; import com.github._1c_syntax.bsl.languageserver.diagnostics.metadata.DiagnosticTag; import com.github._1c_syntax.bsl.languageserver.diagnostics.metadata.DiagnosticType; +import com.github._1c_syntax.bsl.languageserver.diagnostics.typo.CheckedWordsHolder; import com.github._1c_syntax.bsl.languageserver.diagnostics.typo.JLanguageToolPool; +import com.github._1c_syntax.bsl.languageserver.diagnostics.typo.WordStatus; import com.github._1c_syntax.bsl.languageserver.utils.Trees; import com.github._1c_syntax.bsl.parser.BSLParser; import com.github._1c_syntax.bsl.parser.BSLParserRuleContext; import com.github._1c_syntax.utils.CaseInsensitivePattern; import lombok.AccessLevel; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.antlr.v4.runtime.Token; import org.apache.commons.lang3.StringUtils; @@ -49,7 +52,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -63,6 +65,7 @@ } ) @Slf4j +@RequiredArgsConstructor public class TypoDiagnostic extends AbstractDiagnostic { @Getter(lazy = true, value = AccessLevel.PRIVATE) @@ -71,14 +74,6 @@ public class TypoDiagnostic extends AbstractDiagnostic { "ru", new JLanguageToolPool(Languages.getLanguageForShortCode("ru")) ); - /** - * Карта, хранящая результат проверки слова (ошибка/нет ошибки) в разрезе языков. - */ - private static final Map> checkedWords = Map.of( - "en", new ConcurrentHashMap<>(), - "ru", new ConcurrentHashMap<>() - ); - private static final Pattern SPACES_PATTERN = Pattern.compile("\\s+"); private static final Pattern QUOTE_PATTERN = Pattern.compile("\""); private static final String FORMAT_STRING_RU = "Л=|ЧЦ=|ЧДЦ=|ЧС=|ЧРД=|ЧРГ=|ЧН=|ЧВН=|ЧГ=|ЧО=|ДФ=|ДЛФ=|ДП=|БЛ=|БИ="; @@ -100,6 +95,8 @@ public class TypoDiagnostic extends AbstractDiagnostic { private static final int DEFAULT_MIN_WORD_LENGTH = 3; private static final String DEFAULT_USER_WORDS_TO_IGNORE = ""; + private final CheckedWordsHolder checkedWordsHolder; + @DiagnosticParameter( type = Integer.class, defaultValue = "" + DEFAULT_MIN_WORD_LENGTH @@ -166,12 +163,11 @@ private Map> getTokensMap( protected void check() { String lang = info.getResourceString("diagnosticLanguage"); - Map checkedWordsForLang = checkedWords.get(lang); Map> tokensMap = getTokensMap(documentContext); // build string of unchecked words Set uncheckedWords = tokensMap.keySet().stream() - .filter(word -> !checkedWordsForLang.containsKey(word)) + .filter(word -> checkedWordsHolder.getWordStatus(lang, word) == WordStatus.MISSING) .collect(Collectors.toSet()); if (uncheckedWords.isEmpty()) { @@ -201,10 +197,12 @@ protected void check() { // check words and mark matched as checked matches.stream() .map(ruleMatch -> ruleMatch.getSentence().getTokens()[1].getToken()) - .forEach(word -> checkedWordsForLang.put(word, true)); + .forEach(word -> checkedWordsHolder.markWordAsError(lang, word)); // mark unmatched words without errors as checked - uncheckedWords.forEach(word -> checkedWordsForLang.putIfAbsent(word, false)); + uncheckedWords.stream() + .filter(word -> checkedWordsHolder.getWordStatus(lang, word) == WordStatus.MISSING) + .forEach(word -> checkedWordsHolder.markWordAsNoError(lang, word)); fireDiagnosticOnCheckedWordsWithErrors(tokensMap); } @@ -213,10 +211,9 @@ private void fireDiagnosticOnCheckedWordsWithErrors( Map> tokensMap ) { String lang = info.getResourceString("diagnosticLanguage"); - Map checkedWordsForLang = checkedWords.get(lang); tokensMap.entrySet().stream() - .filter(entry -> checkedWordsForLang.getOrDefault(entry.getKey(), false)) + .filter(entry -> checkedWordsHolder.getWordStatus(lang, entry.getKey()) == WordStatus.HAS_ERROR) .forEach((Map.Entry> entry) -> { String word = entry.getKey(); List tokens = entry.getValue(); diff --git a/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolder.java b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolder.java new file mode 100644 index 00000000000..cfe874a8834 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolder.java @@ -0,0 +1,70 @@ +/* + * This file is a part of BSL Language Server. + * + * Copyright (c) 2018-2025 + * Alexey Sosnoviy , Nikita Fedkin 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.diagnostics.typo; + +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +/** + * Компонент для управления постоянным кэшем проверенных слов для диагностики опечаток. + * Использует Spring Cache с EhCache для хранения кэша на диске. + */ +@Component +public class CheckedWordsHolder { + + /** + * Получает статус слова из кэша. + * + * @param lang код языка ("en" или "ru") + * @param word слово, статус которого запрашивается + * @return WordStatus, указывающий, есть ли у слова ошибка, отсутствует ли ошибка, или слово отсутствует в кэше + */ + @Cacheable(value = "typoCache", key = "#lang + ':' + #word", cacheManager = "typoCacheManager") + public WordStatus getWordStatus(String lang, String word) { + return WordStatus.MISSING; + } + + /** + * Помечает слово как содержащее ошибку в кэше. + * + * @param lang код языка ("en" или "ru") + * @param word слово, которое помечается как содержащее ошибку + * @return сохранённый WordStatus, указывающий на наличие ошибки + */ + @CachePut(value = "typoCache", key = "#lang + ':' + #word", cacheManager = "typoCacheManager") + public WordStatus markWordAsError(String lang, String word) { + return WordStatus.HAS_ERROR; + } + + /** + * Помечает слово как не содержащее ошибку в кэше. + * + * @param lang код языка ("en" или "ru") + * @param word слово, которое помечается как не содержащее ошибку + * @return сохранённый WordStatus, указывающий на отсутствие ошибки + */ + @CachePut(value = "typoCache", key = "#lang + ':' + #word", cacheManager = "typoCacheManager") + public WordStatus markWordAsNoError(String lang, String word) { + return WordStatus.NO_ERROR; + } +} diff --git a/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/WordStatus.java b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/WordStatus.java new file mode 100644 index 00000000000..0e8d219df8b --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/WordStatus.java @@ -0,0 +1,42 @@ +/* + * This file is a part of BSL Language Server. + * + * Copyright (c) 2018-2025 + * Alexey Sosnoviy , Nikita Fedkin 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.diagnostics.typo; + +/** + * Статус результата проверки орфографии слова. + */ +public enum WordStatus { + /** + * Слово содержит орфографическую ошибку. + */ + HAS_ERROR, + + /** + * Слово написано правильно. + */ + NO_ERROR, + + /** + * Слово ещё не проверено. + */ + MISSING +} diff --git a/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/CacheConfiguration.java b/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/CacheConfiguration.java index 971f640a2a3..ecc688c56ff 100644 --- a/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/CacheConfiguration.java +++ b/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/CacheConfiguration.java @@ -22,19 +22,44 @@ package com.github._1c_syntax.bsl.languageserver.infrastructure; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github._1c_syntax.bsl.languageserver.diagnostics.typo.WordStatus; +import org.ehcache.config.builders.CacheConfigurationBuilder; +import org.ehcache.config.builders.CacheManagerBuilder; +import org.ehcache.config.builders.ResourcePoolsBuilder; +import org.ehcache.config.units.EntryUnit; +import org.ehcache.config.units.MemoryUnit; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cache.support.SimpleCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.nio.file.Path; +import java.util.List; /** * Spring-конфигурация кэширования. + *

+ * Для typoCache используется EhCache с персистентным хранилищем на диске. + * Для остальных кэшей (например, code lens) используется Caffeine с хранением в памяти. */ @Configuration @EnableCaching public class CacheConfiguration { + + private static final String TYPO_CACHE_NAME = "typoCache"; + + /** + * Основной менеджер кэша, использующий Caffeine для кэширования в памяти. + *

+ * Помечен как {@code @Primary}, поэтому используется для всех кэшей по умолчанию, + * если не указан явно другой менеджер кэша (например, {@code typoCacheManager} для typoCache). + */ @Bean + @Primary public CacheManager cacheManager(Caffeine caffeine) { var caffeineCacheManager = new CaffeineCacheManager(); caffeineCacheManager.setCaffeine(caffeine); @@ -45,4 +70,48 @@ public CacheManager cacheManager(Caffeine caffeine) { public Caffeine caffeineConfig() { return Caffeine.newBuilder(); } + + /** + * Выделенный менеджер EhCache для typoCache с персистентным хранением на диске. + *

+ * Настроен программно, без использования XML-конфигурации. + * При закрытии Spring-контекста вызывается метод {@code close()} для корректного завершения работы кэша. + */ + @Bean(destroyMethod = "close") + public org.ehcache.CacheManager ehcacheManager( + @Value("${app.cache.path}") String cacheDirPath + ) { + var cacheDir = Path.of(cacheDirPath); + + // Configure EhCache cache with disk persistence + var cacheConfig = CacheConfigurationBuilder + .newCacheConfigurationBuilder( + String.class, + WordStatus.class, + ResourcePoolsBuilder.newResourcePoolsBuilder() + .heap(125_000, EntryUnit.ENTRIES) + .disk(50, MemoryUnit.MB, true) + ) + .build(); + + // Build native EhCache manager with persistence + return CacheManagerBuilder.newCacheManagerBuilder() + .with(CacheManagerBuilder.persistence(cacheDir.toFile())) + .withCache(TYPO_CACHE_NAME, cacheConfig) + .build(true); + } + + @Bean + public CacheManager typoCacheManager(org.ehcache.CacheManager ehcacheManager) { + var nativeCache = ehcacheManager.getCache(TYPO_CACHE_NAME, String.class, WordStatus.class); + + // Wrap the native cache with EhCacheAdapter + var simpleCacheManager = new SimpleCacheManager(); + simpleCacheManager.setCaches(List.of( + new EhCacheAdapter<>(nativeCache, TYPO_CACHE_NAME) + )); + simpleCacheManager.afterPropertiesSet(); + + return simpleCacheManager; + } } diff --git a/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapter.java b/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapter.java new file mode 100644 index 00000000000..726480f27ed --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapter.java @@ -0,0 +1,120 @@ +/* + * This file is a part of BSL Language Server. + * + * Copyright (c) 2018-2025 + * Alexey Sosnoviy , Nikita Fedkin 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.infrastructure; + +import org.springframework.cache.Cache; +import org.springframework.cache.support.AbstractValueAdaptingCache; + +import java.util.concurrent.Callable; + +/** + * Адаптер для интеграции нативного EhCache с Spring Cache абстракцией. + *

+ * Оборачивает {@link org.ehcache.Cache} для использования в Spring Cache инфраструктуре, + * обеспечивая корректное взаимодействие между нативным API EhCache и Spring Cache API. + *

+ * Основные особенности: + *

    + *
  • Не допускает хранение null-значений ({@code allowNullValues = false})
  • + *
  • Делегирует все операции нативному EhCache
  • + *
  • Обеспечивает потокобезопасный доступ через {@link #get(Object, Callable)}
  • + *
+ * + * @param тип ключа кэша + * @param тип значения кэша + */ +public class EhCacheAdapter extends AbstractValueAdaptingCache { + + private final org.ehcache.Cache nativeCache; + private final String name; + + /** + * Создает новый адаптер для нативного EhCache. + * + * @param nativeCache нативный кэш EhCache для оборачивания + * @param name имя кэша для идентификации в Spring Cache + */ + public EhCacheAdapter(org.ehcache.Cache nativeCache, String name) { + super(false); // не допускаем null-значения + this.nativeCache = nativeCache; + this.name = name; + } + + @Override + protected Object lookup(Object key) { + @SuppressWarnings("unchecked") + var typedKey = (K) key; + return nativeCache.get(typedKey); + } + + @Override + public String getName() { + return name; + } + + @Override + public Object getNativeCache() { + return nativeCache; + } + + @Override + @SuppressWarnings("unchecked") + public T get(Object key, Callable valueLoader) { + var typedKey = (K) key; + var value = nativeCache.get(typedKey); + + if (value != null) { + return (T) value; + } + + try { + T newValue = valueLoader.call(); + if (newValue != null) { + nativeCache.put(typedKey, (V) newValue); + } + return newValue; + } catch (Exception e) { + throw new Cache.ValueRetrievalException(key, valueLoader, e); + } + } + + @Override + public void put(Object key, Object value) { + @SuppressWarnings("unchecked") + var typedKey = (K) key; + @SuppressWarnings("unchecked") + var typedValue = (V) value; + nativeCache.put(typedKey, typedValue); + } + + @Override + public void evict(Object key) { + @SuppressWarnings("unchecked") + var typedKey = (K) key; + nativeCache.remove(typedKey); + } + + @Override + public void clear() { + nativeCache.clear(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dd3d2a95222..26b4a23e7e5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,6 +12,8 @@ logging.level.org.springframework.data.repository.config.RepositoryConfiguration logging.level.org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean=warn logging.level.org.springframework.context.support.PostProcessorRegistrationDelegate=warn logging.level.org.springframework.core.LocalVariableTableParameterNameDiscoverer=error +logging.level.org.ehcache=warn +app.cache.path=.bsl-ls-cache spring.application.name=BSL Language Server app.globalConfiguration.path=${user.home}/.bsl-language-server.json app.configuration.path=.bsl-language-server.json diff --git a/src/test/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolderTest.java b/src/test/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolderTest.java new file mode 100644 index 00000000000..0ad2d0d953e --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/bsl/languageserver/diagnostics/typo/CheckedWordsHolderTest.java @@ -0,0 +1,95 @@ +/* + * This file is a part of BSL Language Server. + * + * Copyright (c) 2018-2025 + * Alexey Sosnoviy , Nikita Fedkin 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.diagnostics.typo; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class CheckedWordsHolderTest { + + @Autowired + private CheckedWordsHolder checkedWordsHolder; + + @Test + void testPutAndGetWordStatus() { + // given + String lang = "ru"; + String wordWithError = "ошибк" + System.nanoTime(); + String wordWithoutError = "правильно" + System.nanoTime(); + + // when + checkedWordsHolder.markWordAsError(lang, wordWithError); + checkedWordsHolder.markWordAsNoError(lang, wordWithoutError); + + // then + assertThat(checkedWordsHolder.getWordStatus(lang, wordWithError)).isEqualTo(WordStatus.HAS_ERROR); + assertThat(checkedWordsHolder.getWordStatus(lang, wordWithoutError)).isEqualTo(WordStatus.NO_ERROR); + } + + @Test + void testGetWordStatusNotFound() { + // given + String lang = "en"; + String unknownWord = "unknownword" + System.nanoTime(); + + // when + WordStatus status = checkedWordsHolder.getWordStatus(lang, unknownWord); + + // then + assertThat(status).isEqualTo(WordStatus.MISSING); + } + + @Test + void testLanguageSeparation() { + // given + String word = "test" + System.nanoTime(); + + // when + checkedWordsHolder.markWordAsError("en", word); + checkedWordsHolder.markWordAsNoError("ru", word); + + // then + assertThat(checkedWordsHolder.getWordStatus("en", word)).isEqualTo(WordStatus.HAS_ERROR); + assertThat(checkedWordsHolder.getWordStatus("ru", word)).isEqualTo(WordStatus.NO_ERROR); + } + + @Test + void testCacheAnnotations() { + // Test that Spring Cache annotations work + // given + String lang = "en"; + String word = "cachetest" + System.nanoTime(); + + // when - first check MISSING, then put, then get + WordStatus beforePut = checkedWordsHolder.getWordStatus(lang, word); + checkedWordsHolder.markWordAsError(lang, word); + WordStatus afterPut = checkedWordsHolder.getWordStatus(lang, word); + + // then + assertThat(beforePut).isEqualTo(WordStatus.MISSING); + assertThat(afterPut).isEqualTo(WordStatus.HAS_ERROR); + } +} diff --git a/src/test/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapterTest.java b/src/test/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapterTest.java new file mode 100644 index 00000000000..94dc91b6838 --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/bsl/languageserver/infrastructure/EhCacheAdapterTest.java @@ -0,0 +1,293 @@ +/* + * This file is a part of BSL Language Server. + * + * Copyright (c) 2018-2025 + * Alexey Sosnoviy , Nikita Fedkin 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.infrastructure; + +import org.ehcache.Cache; +import org.ehcache.CacheManager; +import org.ehcache.config.builders.CacheConfigurationBuilder; +import org.ehcache.config.builders.CacheManagerBuilder; +import org.ehcache.config.builders.ResourcePoolsBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache.ValueRetrievalException; + +import java.util.concurrent.Callable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EhCacheAdapterTest { + + private CacheManager cacheManager; + private Cache nativeCache; + private EhCacheAdapter ehCacheAdapter; + + @BeforeEach + void setUp() { + cacheManager = CacheManagerBuilder.newCacheManagerBuilder() + .withCache("testCache", + CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, String.class, + ResourcePoolsBuilder.heap(100))) + .build(true); + + nativeCache = cacheManager.getCache("testCache", String.class, String.class); + ehCacheAdapter = new EhCacheAdapter<>(nativeCache, "testCache"); + } + + @AfterEach + void tearDown() { + if (cacheManager != null) { + cacheManager.close(); + } + } + + @Test + void testGetName() { + // when + String name = ehCacheAdapter.getName(); + + // then + assertThat(name).isEqualTo("testCache"); + } + + @Test + void testGetNativeCache() { + // when + Object nativeCacheObject = ehCacheAdapter.getNativeCache(); + + // then + assertThat(nativeCacheObject).isSameAs(nativeCache); + } + + @Test + void testGetWithExistingKey() { + // given + nativeCache.put("key1", "value1"); + + // when + org.springframework.cache.Cache.ValueWrapper result = ehCacheAdapter.get("key1"); + + // then + assertThat(result).isNotNull(); + assertThat(result.get()).isEqualTo("value1"); + } + + @Test + void testGetWithNonExistingKey() { + // when + org.springframework.cache.Cache.ValueWrapper result = ehCacheAdapter.get("nonExistingKey"); + + // then + assertThat(result).isNull(); + } + + @Test + void testGetWithTypeExistingKey() { + // given + nativeCache.put("key1", "value1"); + + // when + String result = ehCacheAdapter.get("key1", String.class); + + // then + assertThat(result).isEqualTo("value1"); + } + + @Test + void testGetWithTypeNonExistingKey() { + // when + String result = ehCacheAdapter.get("nonExistingKey", String.class); + + // then + assertThat(result).isNull(); + } + + @Test + void testGetWithCallableWhenKeyExists() { + // given + nativeCache.put("key1", "existingValue"); + Callable valueLoader = () -> "newValue"; + + // when + String result = ehCacheAdapter.get("key1", valueLoader); + + // then + assertThat(result).isEqualTo("existingValue"); + } + + @Test + void testGetWithCallableWhenKeyDoesNotExist() { + // given + Callable valueLoader = () -> "loadedValue"; + + // when + String result = ehCacheAdapter.get("key2", valueLoader); + + // then + assertThat(result).isEqualTo("loadedValue"); + assertThat(nativeCache.get("key2")).isEqualTo("loadedValue"); + } + + @Test + void testGetWithCallableThrowsException() { + // given + Callable valueLoader = () -> { + throw new RuntimeException("Loader failed"); + }; + + // when / then + assertThatThrownBy(() -> ehCacheAdapter.get("key3", valueLoader)) + .isInstanceOf(ValueRetrievalException.class) + .hasCauseInstanceOf(RuntimeException.class) + .cause() + .hasMessageContaining("Loader failed"); + } + + @Test + void testPut() { + // when + ehCacheAdapter.put("key1", "value1"); + + // then + assertThat(nativeCache.get("key1")).isEqualTo("value1"); + } + + @Test + void testPutOverwritesExistingValue() { + // given + nativeCache.put("key1", "oldValue"); + + // when + ehCacheAdapter.put("key1", "newValue"); + + // then + assertThat(nativeCache.get("key1")).isEqualTo("newValue"); + } + + @Test + void testPutNullValue() { + // when-then + assertThatThrownBy(() -> ehCacheAdapter.put("key1", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testEvict() { + // given + nativeCache.put("key1", "value1"); + + // when + ehCacheAdapter.evict("key1"); + + // then + assertThat(nativeCache.get("key1")).isNull(); + } + + @Test + void testEvictNonExistingKey() { + assertThatNoException().isThrownBy(() -> + ehCacheAdapter.evict("nonExistingKey") + ); + } + + @Test + void testClear() { + // given + nativeCache.put("key1", "value1"); + nativeCache.put("key2", "value2"); + nativeCache.put("key3", "value3"); + + // when + ehCacheAdapter.clear(); + + // then + assertThat(nativeCache.get("key1")).isNull(); + assertThat(nativeCache.get("key2")).isNull(); + assertThat(nativeCache.get("key3")).isNull(); + } + + @Test + void testMultipleOperations() { + // Test a sequence of operations + // put + ehCacheAdapter.put("key1", "value1"); + assertThat(ehCacheAdapter.get("key1", String.class)).isEqualTo("value1"); + + // update + ehCacheAdapter.put("key1", "value2"); + assertThat(ehCacheAdapter.get("key1", String.class)).isEqualTo("value2"); + + // evict + ehCacheAdapter.evict("key1"); + assertThat(ehCacheAdapter.get("key1", String.class)).isNull(); + + // put multiple + ehCacheAdapter.put("key1", "value1"); + ehCacheAdapter.put("key2", "value2"); + assertThat(ehCacheAdapter.get("key1", String.class)).isEqualTo("value1"); + assertThat(ehCacheAdapter.get("key2", String.class)).isEqualTo("value2"); + + // clear all + ehCacheAdapter.clear(); + assertThat(ehCacheAdapter.get("key1", String.class)).isNull(); + assertThat(ehCacheAdapter.get("key2", String.class)).isNull(); + } + + @Test + void testGetWithCallableReturnsNull() { + // given + Callable valueLoader = () -> null; + + // when + String result = ehCacheAdapter.get("key1", valueLoader); + + // then + assertThat(result).isNull(); + assertThat(nativeCache.get("key1")).isNull(); + } + + @Test + void testConcurrentAccess() throws InterruptedException { + // Test that adapter handles concurrent access correctly + Thread t1 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + ehCacheAdapter.put("key" + i, "value" + i); + } + }); + + Thread t2 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + ehCacheAdapter.get("key" + i, String.class); + } + }); + + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + // Verify some values were stored + assertThat(nativeCache.get("key0")).isNotNull(); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c07b3c59a53..5b95400c9ff 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -17,6 +17,8 @@ logging.level.org.eclipse.lsp4j.jsonrpc.RemoteEndpoint=fatal logging.level.org.springframework.test.context.support.AnnotationConfigContextLoaderUtils=warn logging.level.org.springframework.test.context.support.AbstractContextLoader=warn logging.level.org.springframework.boot.test.context.SpringBootTestContextBootstrapper=warn +logging.level.org.ehcache=warn +app.cache.path=${java.io.tmpdir}/bsl-ls-cache-${random.uuid} app.globalConfiguration.path= app.configuration.path= app.websocket.lsp-path=/lsp