From b5950613d2621e10e8399f3b64694c3a4c7085e9 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 00:23:02 +0300 Subject: [PATCH 1/3] refactor(dictionary): centralize loading policy - Make SdefFileProvider own recovery, persistence, result mapping, and registry initialization - Keep cache generation mechanical while distinguishing missing bundled definitions from copy failures - Cover typed failures, cancellation, interruption, recovery, and legacy compatibility boundaries Impact: dictionary loads report actionable failures without changing existing nullable caller behavior --- .../discovery/DictionaryLoadResult.kt | 24 +- .../files/SdefDictionaryFileGenerator.kt | 184 ++++------ .../lang/dictionary/files/SdefFileProvider.kt | 136 ++++++-- .../test/service/DictionaryLoadPolicyTest.kt | 328 ++++++++++++++++++ 4 files changed, 505 insertions(+), 167 deletions(-) create mode 100644 src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt index 1bbc3284..dce0312c 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt @@ -3,27 +3,19 @@ package com.intellij.plugin.applescript.lang.dictionary.discovery import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInfo /** - * Phase 4 SERVICE-09 (plan 04-04 / D-05): sealed return type for - * [com.intellij.plugin.applescript.lang.dictionary.files.SdefFileProvider.fetch]. + * Typed result of one application-dictionary load attempt. * - * Three variants give call sites compile-time exhaustive `when` and a typed channel for - * each fetch outcome: - * - [Empty] the application has no dictionary (legitimate state — e.g. non-scriptable - * app, or a name the discovery sweep could not resolve to a bundle file). - * - [Loaded] dictionary file was generated / cached AND a [DictionaryInfo] was populated. - * - [Failed] a recoverable error occurred during the load (sdef CLI missing, file - * permissions, malformed bundle, etc.). The `cause` carries the underlying - * exception when available. - * - * Service-INTERNAL scope only — this type does NOT appear on any `@JvmStatic` parser-util method - * and is NOT exposed on the parser hot path. PSI-side sealing for resolver / completion shapes is - * deferred until that surface is refactored. + * Three variants give call sites an exhaustive result for each outcome: + * - [Empty] no application or supported dictionary input could be resolved. + * - [Loaded] a dictionary was cached, registered, and initialized. + * - [Failed] generation, recovery, or initialization failed. The `cause` carries the + * underlying exception when available. */ sealed interface DictionaryLoadResult { /** No dictionary available for the requested application. */ object Empty : DictionaryLoadResult - /** Dictionary successfully generated / cached and registered with persistence. */ + /** Dictionary successfully cached, registered, and initialized. */ data class Loaded( val info: DictionaryInfo, ) : DictionaryLoadResult @@ -32,7 +24,7 @@ sealed interface DictionaryLoadResult { * Dictionary fetch failed for [applicationName] with the given [reason]. [cause] is * non-null when the failure originated from an exception (sdef CLI missing, * NotScriptableApplicationException, IO error); null when the failure is a value-level - * predicate (e.g. unsupported file extension, off-EDT guard tripped). + * predicate (e.g. generation or initialization returned no usable output). */ data class Failed( val applicationName: String, diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefDictionaryFileGenerator.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefDictionaryFileGenerator.kt index d3f47daa..0cef4b8f 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefDictionaryFileGenerator.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefDictionaryFileGenerator.kt @@ -3,13 +3,9 @@ package com.intellij.plugin.applescript.lang.dictionary.files import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.SystemInfo -import com.intellij.plugin.applescript.lang.dictionary.discovery.ApplicationDiscoveryService import com.intellij.plugin.applescript.lang.dictionary.discovery.DeveloperToolsNotInstalledException import com.intellij.plugin.applescript.lang.dictionary.discovery.NotScriptableApplicationException import com.intellij.plugin.applescript.lang.dictionary.discovery.XcodeDetectionService -import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInfo -import com.intellij.plugin.applescript.lang.dictionary.persistence.SdefPersistenceService -import com.intellij.plugin.applescript.lang.sdef.ApplicationDictionary import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit @@ -21,63 +17,60 @@ private const val DICTIONARY_GENERATION_TIMEOUT_SECONDS: Long = 5L private val DICTIONARY_FILE_EXTENSIONS: Set = setOf("xml", "sdef") internal object SdefDictionaryFileGenerator { - fun createDictionaryInfoForApplication( + fun generateDictionaryFile( applicationName: String, applicationIoFile: File, - ): DictionaryInfo? { - fun finishGeneration( - targetFile: File, - fileGenerated: Boolean, - ) { - if (!fileGenerated) { - LOG.warn("Error occurred while generating file.") - if (targetFile.delete()) LOG.warn("Created file was deleted") - } else if (ApplicationDiscoveryService.getInstance().removeFromNotFoundList(applicationName)) { - LOG.debug("Application was removed from ignored list") - } - } - - val appExtension = applicationIoFile.extension - val isDictionaryFile = appExtension in DICTIONARY_FILE_EXTENSIONS + generateCacheFile: (() -> Boolean)? = null, + ): File? { + val isDictionaryFile = applicationIoFile.extension in DICTIONARY_FILE_EXTENSIONS val serializePath = serializeDictionaryPathForApplication(applicationName) val targetFile = File(serializePath) val parentDirectoryReady = targetFile.parentFile.exists() || targetFile.parentFile.mkdirs() if ((!SystemInfo.isMac && !isDictionaryFile) || !parentDirectoryReady) return null LOG.debug("=== Caching Dictionary for application [$applicationName] ===") - var fileGenerated = false + var isGenerated = false try { - fileGenerated = - generateDictionaryFileForApplication( - DictionaryGenerationRequest( - applicationName, - applicationIoFile, - targetFile, - serializePath, - isDictionaryFile, - ), - ) - } catch (e: NotScriptableApplicationException) { - LOG.warn("Generation failed: ${e.message}. Adding to ignore list") - service().addNotScriptable(e.applicationName) - } catch (e: DeveloperToolsNotInstalledException) { - LOG.warn("Generation failed: ${e.message}") - fileGenerated = - recoverDictionaryFileFromBundledSdef( - applicationName, - applicationIoFile, - targetFile, - ) + isGenerated = + generateCacheFile?.invoke() + ?: writeCacheFile( + DictionaryGenerationRequest( + applicationName, + applicationIoFile, + targetFile, + serializePath, + isDictionaryFile, + ), + ) } finally { - finishGeneration(targetFile, fileGenerated) + if (!isGenerated) { + LOG.warn("Error occurred while generating file.") + if (targetFile.delete()) LOG.warn("Created file was deleted") + } } + return targetFile.takeIf { isGenerated && it.exists() } + } + + fun recoverDictionaryFile( + applicationName: String, + applicationIoFile: File, + copyDictionaryFile: (String, File, File, Boolean) -> Boolean = ::copyDictionaryFileToCacheDir, + ): File? { + LOG.warn("Will try to find application scripting definition file...") + val sdefFile = findSdefForApplication(applicationIoFile) + if (sdefFile == null || !sdefFile.exists()) { + LOG.warn("Scripting definition was not found for application ${applicationIoFile.absolutePath}") + return null + } + val targetFile = File(serializeDictionaryPathForApplication(applicationName)) + if (!copyDictionaryFile(applicationName, sdefFile, targetFile, true)) { + throw IOException("Failed to cache bundled dictionary for $applicationName") + } return targetFile - .takeIf { fileGenerated && it.exists() } - ?.let { registerGeneratedDictionaryInfo(applicationName, it, applicationIoFile, appExtension) } } - private fun generateDictionaryFileForApplication(request: DictionaryGenerationRequest): Boolean = + private fun writeCacheFile(request: DictionaryGenerationRequest): Boolean = if (SystemInfo.isMac) { val cmdName = if (request.isDictionaryFile) "cat" else "sdef" doGenerateDictionaryFile( @@ -95,71 +88,40 @@ internal object SdefDictionaryFileGenerator { ) } - @Throws(NotScriptableApplicationException::class, DeveloperToolsNotInstalledException::class) + @Throws( + NotScriptableApplicationException::class, + DeveloperToolsNotInstalledException::class, + InterruptedException::class, + IOException::class, + ) private fun doGenerateDictionaryFile( applicationName: String, serializePath: String, cmdName: String, appFilePath: String, ): Boolean { - fun logFailure(cause: Throwable) { - LOG.error( - "Failed to create dictionary file for application [$applicationName] Command:$cmdName " + - "target path: $appFilePath Reason: ${cause.cause}", - cause, - ) - } - - var isGenerated = false - try { - val shellCommand = arrayOf("/bin/bash", "-c", " $cmdName \"$appFilePath\" > $serializePath") - LOG.debug("executing command: ${shellCommand.contentToString()}") - val execStart = System.currentTimeMillis() - val exitStatus = - Runtime - .getRuntime() - .exec(shellCommand) - .waitFor(DICTIONARY_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) - val execEnd = System.currentTimeMillis() - if (!exitStatus) { - if (service().isXcodeInstalled()) { - throw NotScriptableApplicationException( - applicationName, - "Waiting time elapsed for command ${shellCommand.contentToString()}. " + - "Seems that application \"$applicationName\" is not scriptable.", - ) - } else { - throw DeveloperToolsNotInstalledException() - } + val shellCommand = arrayOf("/bin/bash", "-c", " $cmdName \"$appFilePath\" > $serializePath") + LOG.debug("executing command: ${shellCommand.contentToString()}") + val execStart = System.currentTimeMillis() + val isFinished = + Runtime + .getRuntime() + .exec(shellCommand) + .waitFor(DICTIONARY_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + val execEnd = System.currentTimeMillis() + if (!isFinished) { + if (service().isXcodeInstalled()) { + throw NotScriptableApplicationException( + applicationName, + "Waiting time elapsed for command ${shellCommand.contentToString()}. " + + "Seems that application \"$applicationName\" is not scriptable.", + ) + } else { + throw DeveloperToolsNotInstalledException() } - LOG.debug("Waiting time elapsed. Execution time: ${execEnd - execStart} ms.") - isGenerated = true - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - logFailure(e) - } catch (e: IOException) { - logFailure(e) - } - return isGenerated - } - - private fun recoverDictionaryFileFromBundledSdef( - applicationName: String, - applicationIoFile: File, - targetFile: File, - ): Boolean { - LOG.warn("Will try to find application scripting definition file...") - val sdefFile = findSdefForApplication(applicationIoFile) - return if (sdefFile != null && sdefFile.exists()) { - copyDictionaryFileToCacheDir(applicationName, sdefFile, targetFile, true) - } else { - LOG.warn( - "Scripting definition was not found for application " + - applicationIoFile.absolutePath, - ) - service().addNotScriptable(applicationName) - false } + LOG.debug("Waiting time elapsed. Execution time: ${execEnd - execStart} ms.") + return true } private fun findSdefForApplication(applicationIoFile: File): File? { @@ -167,22 +129,6 @@ internal object SdefDictionaryFileGenerator { val files = appResources.listFiles { _, fileName -> fileName.endsWith("sdef") } return if (!files.isNullOrEmpty()) files[0] else null } - - private fun registerGeneratedDictionaryInfo( - applicationName: String, - targetFile: File, - applicationIoFile: File, - appExtension: String, - ): DictionaryInfo { - val applicationBundle = - applicationIoFile.takeIf { - ApplicationDictionary.SUPPORTED_APPLICATION_EXTENSIONS.contains(appExtension) - } - val dictionaryInfo = DictionaryInfo(applicationName, targetFile, applicationBundle) - service().addDictionaryInfo(dictionaryInfo) - LOG.debug("Dictionary file generated for application [$applicationName]$targetFile") - return dictionaryInfo - } } private data class DictionaryGenerationRequest( diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt index 20da32f9..f1f4c5c2 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt @@ -45,21 +45,9 @@ class SdefFileProvider discoveryService.findApplicationBundleFile(applicationName) ?: return@withContext DictionaryLoadResult.Empty try { - val info = - createAndInitializeInfo(applicationFile, applicationName) - ?: return@withContext DictionaryLoadResult.Empty - DictionaryLoadResult.Loaded(info) + loadDictionary(applicationFile, applicationName) } catch (e: CancellationException) { throw e - } catch (e: NotScriptableApplicationException) { - service().addNotScriptable(applicationName) - DictionaryLoadResult.Failed(applicationName, "Application is not scriptable", e) - } catch (e: DeveloperToolsNotInstalledException) { - DictionaryLoadResult.Failed( - applicationName, - "Developer Tools not installed (sdef CLI unavailable)", - e, - ) } catch (e: IllegalStateException) { LOG.error("Failed to fetch dictionary for $applicationName", e) DictionaryLoadResult.Failed(applicationName, "Unexpected error: ${e.message}", e) @@ -69,30 +57,114 @@ class SdefFileProvider } } + @Synchronized + internal fun loadDictionary( + applicationIoFile: File, + applicationName: String, + generateDictionaryFile: (String, File) -> File? = SdefDictionaryFileGenerator::generateDictionaryFile, + recoverDictionaryFile: (String, File) -> File? = { name, file -> + SdefDictionaryFileGenerator.recoverDictionaryFile(name, file) + }, + ): DictionaryLoadResult { + if (!extensionSupported(applicationIoFile.extension) || !applicationIoFile.exists()) { + return DictionaryLoadResult.Empty + } + val facade = AppleScriptSystemDictionaryRegistryService.getInstance() + if (facade.getDictionaryInfoByNameInternal(applicationName) != null) { + LOG.warn( + "Dictionary for application $applicationName was already initialized. " + + "Generating new dictionary file any way.", + ) + } + + return try { + val dictionaryFile = + generateFile( + applicationName, + applicationIoFile, + generateDictionaryFile, + recoverDictionaryFile, + ) ?: return DictionaryLoadResult.Failed( + applicationName, + "Dictionary file generation failed", + ) + initializeDictionary( + facade, + registerDictionary(applicationName, applicationIoFile, dictionaryFile), + ) + } catch (e: CancellationException) { + throw e + } catch (e: NotScriptableApplicationException) { + service().addNotScriptable(e.applicationName) + DictionaryLoadResult.Failed(applicationName, "Application is not scriptable", e) + } catch (e: DeveloperToolsNotInstalledException) { + failedDeveloperToolsLoad(applicationName, e) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + LOG.warn("Dictionary generation was interrupted for $applicationName", e) + DictionaryLoadResult.Failed(applicationName, "Dictionary generation interrupted", e) + } catch (e: IOException) { + LOG.warn("Dictionary file loading failed for $applicationName", e) + DictionaryLoadResult.Failed(applicationName, "Dictionary file I/O error: ${e.message}", e) + } + } + @Synchronized fun createAndInitializeInfo( applicationIoFile: File, applicationName: String, - ): DictionaryInfo? { - val createdDictionaryInfo = - if (!extensionSupported(applicationIoFile.extension) || !applicationIoFile.exists()) { - null - } else { - val facade = AppleScriptSystemDictionaryRegistryService.getInstance() - if (facade.getDictionaryInfoByNameInternal(applicationName) != null) { - LOG.warn( - "Dictionary for application $applicationName was already initialized. " + - "Generating new dictionary file any way.", - ) - } - val dictionaryInfo = - SdefDictionaryFileGenerator.createDictionaryInfoForApplication( - applicationName, - applicationIoFile, - ) - dictionaryInfo?.takeIf { facade.initializeDictionaryFromInfoInternal(it) } + ): DictionaryInfo? = (loadDictionary(applicationIoFile, applicationName) as? DictionaryLoadResult.Loaded)?.info + + private fun generateFile( + applicationName: String, + applicationFile: File, + generate: (String, File) -> File?, + recover: (String, File) -> File?, + ): File? = + try { + generate(applicationName, applicationFile) + } catch (e: DeveloperToolsNotInstalledException) { + recover(applicationName, applicationFile) ?: throw e + } + + private fun registerDictionary( + applicationName: String, + applicationFile: File, + dictionaryFile: File, + ): DictionaryInfo { + val applicationBundle = + applicationFile.takeIf { + ApplicationDictionary.SUPPORTED_APPLICATION_EXTENSIONS.contains(applicationFile.extension) } - return createdDictionaryInfo + val info = DictionaryInfo(applicationName, dictionaryFile, applicationBundle) + if (ApplicationDiscoveryService.getInstance().removeFromNotFoundList(applicationName)) { + LOG.debug("Application was removed from ignored list") + } + service().addDictionaryInfo(info) + LOG.debug("Dictionary file generated for application [$applicationName]$dictionaryFile") + return info + } + + private fun initializeDictionary( + facade: AppleScriptSystemDictionaryRegistryService, + info: DictionaryInfo, + ): DictionaryLoadResult = + if (facade.initializeDictionaryFromInfoInternal(info)) { + DictionaryLoadResult.Loaded(info) + } else { + DictionaryLoadResult.Failed(info.getApplicationName(), "Dictionary initialization failed") + } + + private fun failedDeveloperToolsLoad( + applicationName: String, + cause: DeveloperToolsNotInstalledException, + ): DictionaryLoadResult.Failed { + service().addNotScriptable(applicationName) + return DictionaryLoadResult.Failed( + applicationName, + "Developer Tools not installed (sdef CLI unavailable)", + cause, + ) } fun initializeScriptingAdditions() { diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt new file mode 100644 index 00000000..4bb11433 --- /dev/null +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt @@ -0,0 +1,328 @@ +package com.intellij.plugin.applescript.test.service + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.plugin.applescript.lang.dictionary.discovery.DeveloperToolsNotInstalledException +import com.intellij.plugin.applescript.lang.dictionary.discovery.DictionaryLoadResult +import com.intellij.plugin.applescript.lang.dictionary.discovery.NotScriptableApplicationException +import com.intellij.plugin.applescript.lang.dictionary.files.SdefDictionaryFileGenerator +import com.intellij.plugin.applescript.lang.dictionary.files.SdefFileProvider +import com.intellij.plugin.applescript.lang.dictionary.files.serializeDictionaryPathForApplication +import com.intellij.plugin.applescript.lang.dictionary.persistence.SdefPersistenceService +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CancellationException +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +class DictionaryLoadPolicyTest : BasePlatformTestCase() { + fun testMalformedSdefReturnsNull() { + val applicationName = "SyntheticMalformed_${System.nanoTime()}" + val dictionaryFile = Files.createTempFile("provider-malformed-", ".sdef").toFile() + val generatedFile = File(serializeDictionaryPathForApplication(applicationName)) + dictionaryFile.writeText("") + + try { + assertNull( + "Legacy file interface must collapse initialization failure to null", + SdefFileProvider.getInstance().createAndInitializeInfo(dictionaryFile, applicationName), + ) + } finally { + cleanApplication(applicationName) + dictionaryFile.delete() + generatedFile.delete() + } + } + + fun testLoadReportsIoFailure() { + val applicationName = "SyntheticIoFailure_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val failure = IOException("Synthetic I/O failure") + + try { + val result = + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw failure }, + ) + + assertEquals( + DictionaryLoadResult.Failed( + applicationName, + "Dictionary file I/O error: ${failure.message}", + failure, + ), + result, + ) + } finally { + applicationFile.parentFile.deleteRecursively() + } + } + + fun testLoadPreservesInterrupt() { + if (!SystemInfo.isMac) return + + val applicationName = "SyntheticInterruptedLoad_${System.nanoTime()}" + val dictionaryFile = + SyntheticSuiteFixtures.writeToTempFile( + "provider-interrupt", + SyntheticSuiteFixtures.emptySuiteXml(), + ) + val generatedFile = File(serializeDictionaryPathForApplication(applicationName)) + val future = + ApplicationManager.getApplication().executeOnPooledThread { + Thread.currentThread().interrupt() + try { + val result = SdefFileProvider.getInstance().loadDictionary(dictionaryFile, applicationName) + + assertTrue( + "Interrupted generation must return a typed failure; got $result", + result is DictionaryLoadResult.Failed, + ) + assertTrue( + "Interrupted generation must preserve its cause", + (result as DictionaryLoadResult.Failed).cause is InterruptedException, + ) + assertTrue( + "Interrupted generation must restore the worker flag", + Thread.currentThread().isInterrupted, + ) + } finally { + Thread.interrupted() + } + } + + try { + future.get(5, TimeUnit.SECONDS) + assertFalse("Interrupted generation must remove its partial cache file", generatedFile.exists()) + } finally { + cleanApplication(applicationName) + dictionaryFile.delete() + generatedFile.delete() + } + } + + fun testLoadRecoversBundledFile() { + val applicationName = "SyntheticBundledRecovery_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val dictionaryFile = + SyntheticSuiteFixtures.writeToTempFile( + "provider-recovery", + SyntheticSuiteFixtures.musicAppPlayCommandXml(), + ) + val failure = DeveloperToolsNotInstalledException() + + try { + val result = + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw failure }, + recoverDictionaryFile = { _, _ -> dictionaryFile }, + ) + + assertTrue( + "Bundled recovery must produce a loaded dictionary; got $result", + result is DictionaryLoadResult.Loaded, + ) + val info = (result as DictionaryLoadResult.Loaded).info + assertEquals(applicationName, info.getApplicationName()) + assertEquals(dictionaryFile.path, info.getDictionaryFile().path) + assertTrue("Recovered dictionary must be initialized", info.initialized) + } finally { + cleanApplication(applicationName) + applicationFile.parentFile.deleteRecursively() + dictionaryFile.delete() + } + } + + fun testMissingRecoveryFailsLoad() { + val applicationName = "SyntheticMissingRecovery_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val failure = DeveloperToolsNotInstalledException() + + try { + val result = + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw failure }, + recoverDictionaryFile = { _, _ -> null }, + ) + + assertEquals( + DictionaryLoadResult.Failed( + applicationName, + "Developer Tools not installed (sdef CLI unavailable)", + failure, + ), + result, + ) + assertTrue( + "Unrecoverable application must update persisted discovery state", + SdefPersistenceService.getInstance().isNotScriptable(applicationName), + ) + } finally { + cleanApplication(applicationName) + applicationFile.parentFile.deleteRecursively() + } + } + + fun testRecoveryCopyFailure() { + val applicationName = "SyntheticRecoveryCopyFailure_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val resources = File(applicationFile, "Contents/Resources") + val bundledDictionary = File(resources, "Synthetic.sdef") + assertTrue("Synthetic resources directory must be created", resources.mkdirs()) + bundledDictionary.writeText(SyntheticSuiteFixtures.emptySuiteXml()) + val generationFailure = DeveloperToolsNotInstalledException() + + try { + val result = + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw generationFailure }, + recoverDictionaryFile = { name, file -> + SdefDictionaryFileGenerator.recoverDictionaryFile( + name, + file, + copyDictionaryFile = { _, _, _, _ -> false }, + ) + }, + ) + + assertTrue( + "Cache-copy failure must return a typed failure; got $result", + result is DictionaryLoadResult.Failed, + ) + assertTrue( + "Cache-copy failure must preserve its I/O category", + (result as DictionaryLoadResult.Failed).cause is IOException, + ) + assertEquals( + "Dictionary file I/O error: Failed to cache bundled dictionary for $applicationName", + result.reason, + ) + assertFalse( + "Transient cache-copy failure must not blacklist the application", + SdefPersistenceService.getInstance().isNotScriptable(applicationName), + ) + } finally { + cleanApplication(applicationName) + applicationFile.parentFile.deleteRecursively() + } + } + + fun testCancellationEscapesLoad() { + val applicationName = "SyntheticCancelledLoad_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val cancellation = CancellationException("Synthetic cancellation") + + try { + val thrown = + runCatching { + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw cancellation }, + ) + }.exceptionOrNull() + + assertSame("Cancellation must escape the loading module", cancellation, thrown) + } finally { + applicationFile.parentFile.deleteRecursively() + } + } + + fun testIllegalStateEscapes() { + val applicationName = "SyntheticIllegalState_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val failure = IllegalStateException("Synthetic programming error") + + try { + val thrown = + runCatching { + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw failure }, + ) + }.exceptionOrNull() + + assertSame("Programming errors must escape the shared load path", failure, thrown) + } finally { + applicationFile.parentFile.deleteRecursively() + } + } + + fun testGeneratorThrowsFailure() { + val applicationName = "SyntheticGeneratorFailure_${System.nanoTime()}" + val dictionaryFile = + SyntheticSuiteFixtures.writeToTempFile( + "provider-generator-failure", + SyntheticSuiteFixtures.emptySuiteXml(), + ) + val failure = NotScriptableApplicationException(applicationName, "Synthetic failure") + + try { + val thrown = + runCatching { + SdefDictionaryFileGenerator.generateDictionaryFile(applicationName, dictionaryFile) { + throw failure + } + }.exceptionOrNull() + + assertSame("Generator must preserve the original failure", failure, thrown) + assertFalse( + "Generator must not own persistence policy", + SdefPersistenceService.getInstance().isNotScriptable(applicationName), + ) + } finally { + cleanApplication(applicationName) + dictionaryFile.delete() + } + } + + fun testNotScriptableLoadFails() { + val applicationName = "SyntheticNotScriptable_${System.nanoTime()}" + val applicationFile = tempApplication(applicationName) + val failure = NotScriptableApplicationException(applicationName, "Synthetic failure") + + try { + val result = + SdefFileProvider.getInstance().loadDictionary( + applicationFile, + applicationName, + generateDictionaryFile = { _, _ -> throw failure }, + ) + + assertEquals( + DictionaryLoadResult.Failed(applicationName, "Application is not scriptable", failure), + result, + ) + assertTrue( + "Not-scriptable failure must update persisted discovery state", + SdefPersistenceService.getInstance().isNotScriptable(applicationName), + ) + } finally { + cleanApplication(applicationName) + applicationFile.parentFile.deleteRecursively() + } + } + + private fun cleanApplication(applicationName: String) { + val persistence = SdefPersistenceService.getInstance() + persistence.removeDictionaryInfoByNameForTests(applicationName) + persistence.removeNotScriptable(applicationName) + } + + private fun tempApplication(applicationName: String): File { + val parent = Files.createTempDirectory("sdef-provider-app-").toFile() + return File(parent, "$applicationName.app").also { applicationFile -> + assertTrue("Synthetic application directory must be created", applicationFile.mkdir()) + } + } +} From 571b96776e2cf48e823cb6529e13cb70097cb319 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 00:48:08 +0300 Subject: [PATCH 2/3] fix(dictionary): preserve load failure context --- .../applescript/lang/dictionary/files/SdefFileProvider.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt index f1f4c5c2..3f3a3600 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt @@ -95,6 +95,7 @@ class SdefFileProvider } catch (e: CancellationException) { throw e } catch (e: NotScriptableApplicationException) { + LOG.warn("Dictionary generation failed for $applicationName: ${e.message}", e) service().addNotScriptable(e.applicationName) DictionaryLoadResult.Failed(applicationName, "Application is not scriptable", e) } catch (e: DeveloperToolsNotInstalledException) { @@ -124,6 +125,7 @@ class SdefFileProvider try { generate(applicationName, applicationFile) } catch (e: DeveloperToolsNotInstalledException) { + LOG.warn("Dictionary generation failed for $applicationName: ${e.message}", e) recover(applicationName, applicationFile) ?: throw e } From 9ec7a9d2ec112f9c0a70def607658a53049bbec9 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 01:03:00 +0300 Subject: [PATCH 3/3] docs(dictionary): align loading terminology --- .../dictionary/discovery/DictionaryLoadResult.kt | 6 +++--- .../lang/dictionary/files/SdefFileProvider.kt | 16 ++++++++-------- .../test/service/DictionaryLoadPolicyTest.kt | 8 ++++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt index dce0312c..3f583ae9 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/discovery/DictionaryLoadResult.kt @@ -5,11 +5,11 @@ import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInf /** * Typed result of one application-dictionary load attempt. * - * Three variants give call sites an exhaustive result for each outcome: + * Three variants give call sites an exhaustive returned outcome: * - [Empty] no application or supported dictionary input could be resolved. * - [Loaded] a dictionary was cached, registered, and initialized. - * - [Failed] generation, recovery, or initialization failed. The `cause` carries the - * underlying exception when available. + * - [Failed] a load step failed or `fetch` converted an unexpected load error. The `cause` + * carries the underlying exception when available. */ sealed interface DictionaryLoadResult { /** No dictionary available for the requested application. */ diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt index 3f3a3600..c1c12fe7 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/files/SdefFileProvider.kt @@ -24,11 +24,11 @@ import java.util.concurrent.ConcurrentHashMap private val LOG: Logger = Logger.getInstance("#${SdefFileProvider::class.java.name}") /** - * Application-level owner for generated SDEF files and scripting-additions state. + * Application-level owner for dictionary-file loading and scripting-additions state. * - * The service coordinates application dictionary generation, bundled standard suites, and - * merged scripting-additions dictionaries. Parsing and index ingestion stay on the downstream - * registry/index services. + * The service coordinates generation and bundled recovery, classifies load outcomes, applies + * persistence policy, and initializes dictionaries through the registry. Parsing and index + * ingestion stay on the downstream registry/index services. */ @Service(Service.Level.APP) class SdefFileProvider @@ -72,8 +72,8 @@ class SdefFileProvider val facade = AppleScriptSystemDictionaryRegistryService.getInstance() if (facade.getDictionaryInfoByNameInternal(applicationName) != null) { LOG.warn( - "Dictionary for application $applicationName was already initialized. " + - "Generating new dictionary file any way.", + "Dictionary for application $applicationName was already registered. " + + "Generating a new dictionary file anyway.", ) } @@ -140,10 +140,10 @@ class SdefFileProvider } val info = DictionaryInfo(applicationName, dictionaryFile, applicationBundle) if (ApplicationDiscoveryService.getInstance().removeFromNotFoundList(applicationName)) { - LOG.debug("Application was removed from ignored list") + LOG.debug("Application was removed from the not-found list") } service().addDictionaryInfo(info) - LOG.debug("Dictionary file generated for application [$applicationName]$dictionaryFile") + LOG.debug("Dictionary file registered for application [$applicationName]: $dictionaryFile") return info } diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt index 4bb11433..cd474da9 100644 --- a/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/service/DictionaryLoadPolicyTest.kt @@ -25,7 +25,7 @@ class DictionaryLoadPolicyTest : BasePlatformTestCase() { try { assertNull( - "Legacy file interface must collapse initialization failure to null", + "Nullable file interface must collapse initialization failure to null", SdefFileProvider.getInstance().createAndInitializeInfo(dictionaryFile, applicationName), ) } finally { @@ -124,7 +124,7 @@ class DictionaryLoadPolicyTest : BasePlatformTestCase() { ) assertTrue( - "Bundled recovery must produce a loaded dictionary; got $result", + "Recovery callback must produce a loaded dictionary; got $result", result is DictionaryLoadResult.Loaded, ) val info = (result as DictionaryLoadResult.Loaded).info @@ -161,7 +161,7 @@ class DictionaryLoadPolicyTest : BasePlatformTestCase() { result, ) assertTrue( - "Unrecoverable application must update persisted discovery state", + "Missing Developer Tools recovery must update persisted discovery state", SdefPersistenceService.getInstance().isNotScriptable(applicationName), ) } finally { @@ -207,7 +207,7 @@ class DictionaryLoadPolicyTest : BasePlatformTestCase() { result.reason, ) assertFalse( - "Transient cache-copy failure must not blacklist the application", + "Cache-copy failure must not blacklist the application", SdefPersistenceService.getInstance().isNotScriptable(applicationName), ) } finally {