diff --git a/language-server/build.gradle.kts b/language-server/build.gradle.kts index 7f7d8100..25afa76e 100644 --- a/language-server/build.gradle.kts +++ b/language-server/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(project(":lexer")) implementation(project(":parser")) implementation(project(":semantic")) + implementation(project(":samt-config")) } application { diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtConfig.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtConfig.kt new file mode 100644 index 00000000..c2af0bc6 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtConfig.kt @@ -0,0 +1,21 @@ +package tools.samt.ls + +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.isRegularFile + +val SAMT_CONFIG_FILE_NAME = Path("samt.yaml") + +fun findSamtConfigs(path: Path): List { + fun Path.parents(): Sequence = generateSequence(parent) { it.parent } + + return path.toFile().walkTopDown() + .map { it.toPath() } + .filter { it.fileName == SAMT_CONFIG_FILE_NAME && it.isRegularFile() } + .ifEmpty { + path.parents() + .map { it.resolve(SAMT_CONFIG_FILE_NAME) } + .filter { it.isRegularFile() } + .take(1) + }.toList() +} \ No newline at end of file diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt index 1ec12a10..81c6f76d 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt @@ -4,17 +4,24 @@ import tools.samt.common.DiagnosticController import tools.samt.common.DiagnosticMessage import tools.samt.common.collectSamtFiles import tools.samt.common.readSamtSource +import tools.samt.config.SamtConfigurationParser import tools.samt.semantic.SemanticModel import java.net.URI +import kotlin.io.path.toPath -class SamtFolder(val path: URI) : Iterable { +class SamtFolder(val configPath: URI, val sourcePath: URI) : Iterable { private val files = mutableMapOf() var semanticModel: SemanticModel? = null private set - private var semanticController: DiagnosticController = DiagnosticController(path) + private var semanticController: DiagnosticController = DiagnosticController(sourcePath) + + init { + require(configPath.toPath().fileName == SAMT_CONFIG_FILE_NAME) + } fun set(fileInfo: FileInfo) { - files[fileInfo.sourceFile.path] = fileInfo + require(fileInfo.path.startsWith(sourcePath)) + files[fileInfo.path] = fileInfo } fun remove(fileUri: URI) { @@ -32,7 +39,7 @@ class SamtFolder(val path: URI) : Iterable { } fun buildSemanticModel() { - semanticController = DiagnosticController(path) + semanticController = DiagnosticController(sourcePath) semanticModel = SemanticModel.build(mapNotNull { it.fileNode }, semanticController) } @@ -47,14 +54,21 @@ class SamtFolder(val path: URI) : Iterable { } companion object { - fun fromDirectory(path: URI): SamtFolder { - val controller = DiagnosticController(path) - val workspace = SamtFolder(path) - val sourceFiles = collectSamtFiles(path).readSamtSource(controller) - sourceFiles.forEach { - workspace.set(parseFile(it)) + fun fromConfig(configPath: URI): SamtFolder? { + val folder = + try { + configPath.toPath().let { + val config = SamtConfigurationParser.parseConfiguration(it) + SamtFolder(configPath.normalize(), it.resolveSibling(config.source).normalize().toUri()) + } + } catch (e: SamtConfigurationParser.ParseException) { + return null + } + val sourceFiles = collectSamtFiles(folder.sourcePath).readSamtSource(DiagnosticController(folder.sourcePath)) + for (file in sourceFiles) { + folder.set(parseFile(file)) } - return workspace + return folder } } } diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt index ff7e6eab..72b14191 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt @@ -7,6 +7,7 @@ import java.io.Closeable import java.util.* import java.util.concurrent.CompletableFuture import java.util.logging.Logger +import kotlin.io.path.toPath import kotlin.system.exitProcess class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { @@ -89,10 +90,13 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { } private fun buildSamtModel(params: InitializeParams) { - val folders = params.workspaceFolders?.map { it.uri.toPathUri() }.orEmpty() - for (folder in folders) { - workspace.addFolder(SamtFolder.fromDirectory(folder)) - } + params.workspaceFolders + ?.flatMap { folder -> + val path = folder.uri.toPathUri().toPath() + findSamtConfigs(path).mapNotNull { + SamtFolder.fromConfig(it.toUri()) + } + }?.forEach(workspace::addFolder) } private fun registerFileWatchCapability() { @@ -107,6 +111,9 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { globPattern = Either.forLeft("**/") kind = WatchKind.Create or WatchKind.Delete }, + FileSystemWatcher().apply { + globPattern = Either.forLeft("**/$SAMT_CONFIG_FILE_NAME") + } ) }) ))) diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt index 067391c6..dfe8103f 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt @@ -13,9 +13,10 @@ import tools.samt.parser.OperationNode import tools.samt.semantic.* import java.util.concurrent.CompletableFuture import java.util.logging.Logger +import kotlin.io.path.toPath class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocumentService, - LanguageClientAware { + LanguageClientAware { private lateinit var client: LanguageClient private val logger = Logger.getLogger("SamtTextDocumentService") @@ -24,6 +25,12 @@ class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocume val path = params.textDocument.uri.toPathUri() val text = params.textDocument.text + if (!workspace.containsFile(path)) { + val configPath = findSamtConfigs(path.toPath().parent).singleOrNull() + configPath + ?.let { SamtFolder.fromConfig(it.toUri()) } + ?.let { workspace.addFolder(it) } + } workspace.setFile(parseFile(SourceFile(path, text))) client.updateWorkspace(workspace) } @@ -52,110 +59,110 @@ class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocume } override fun definition(params: DefinitionParams): CompletableFuture, List>> = - CompletableFuture.supplyAsync { - val path = params.textDocument.uri.toPathUri() + CompletableFuture.supplyAsync { + val path = params.textDocument.uri.toPathUri() - val fileInfo = workspace.getFile(path) ?: return@supplyAsync Either.forRight(emptyList()) + val fileInfo = workspace.getFile(path) ?: return@supplyAsync Either.forRight(emptyList()) - val fileNode: FileNode = fileInfo.fileNode ?: return@supplyAsync Either.forRight(emptyList()) - val semanticModel = workspace.getSemanticModel(path) ?: return@supplyAsync Either.forRight(emptyList()) - val globalPackage: Package = semanticModel.global + val fileNode: FileNode = fileInfo.fileNode ?: return@supplyAsync Either.forRight(emptyList()) + val semanticModel = workspace.getSemanticModel(path) ?: return@supplyAsync Either.forRight(emptyList()) + val globalPackage: Package = semanticModel.global - val token = fileInfo.tokens.findAt(params.position) ?: return@supplyAsync Either.forRight(emptyList()) + val token = fileInfo.tokens.findAt(params.position) ?: return@supplyAsync Either.forRight(emptyList()) - val filePackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) + val filePackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) - val typeLookup = SamtDeclarationLookup.analyze(fileNode, filePackage, semanticModel.userMetadata) - val type = typeLookup[token.location] ?: return@supplyAsync Either.forRight(emptyList()) + val typeLookup = SamtDeclarationLookup.analyze(fileNode, filePackage, semanticModel.userMetadata) + val type = typeLookup[token.location] ?: return@supplyAsync Either.forRight(emptyList()) - val definition = type.declaration - val location = definition.location + val definition = type.declaration + val location = definition.location - val targetLocation = when (definition) { - is NamedDeclarationNode -> definition.name.location - is OperationNode -> definition.name.location - else -> error("Unexpected definition type") - } - val locationLink = LocationLink().apply { - targetUri = location.source.path.toString() - targetRange = location.toRange() - targetSelectionRange = targetLocation.toRange() - } - return@supplyAsync Either.forRight(listOf(locationLink)) + val targetLocation = when (definition) { + is NamedDeclarationNode -> definition.name.location + is OperationNode -> definition.name.location + else -> error("Unexpected definition type") } + val locationLink = LocationLink().apply { + targetUri = location.source.path.toString() + targetRange = location.toRange() + targetSelectionRange = targetLocation.toRange() + } + return@supplyAsync Either.forRight(listOf(locationLink)) + } override fun references(params: ReferenceParams): CompletableFuture> = - CompletableFuture.supplyAsync { - val path = params.textDocument.uri.toPathUri() - - val relevantFileInfo = workspace.getFile(path) ?: return@supplyAsync emptyList() - val relevantFileNode = relevantFileInfo.fileNode ?: return@supplyAsync emptyList() - val token = relevantFileInfo.tokens.findAt(params.position) ?: return@supplyAsync emptyList() - - val (_, files, semanticModel) = workspace.getFolderSnapshot(path) ?: return@supplyAsync emptyList() - if (semanticModel == null) return@supplyAsync emptyList() - - val globalPackage = semanticModel.global - val typeLookup = SamtDeclarationLookup.analyze( - relevantFileNode, - globalPackage.resolveSubPackage(relevantFileInfo.fileNode.packageDeclaration.name), - semanticModel.userMetadata - ) - val type = typeLookup[token.location] ?: return@supplyAsync emptyList() - - val filesAndPackages = buildList { - for (fileInfo in files) { - val fileNode: FileNode = fileInfo.fileNode ?: continue - val filePackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) - add(fileNode to filePackage) - } + CompletableFuture.supplyAsync { + val path = params.textDocument.uri.toPathUri() + + val relevantFileInfo = workspace.getFile(path) ?: return@supplyAsync emptyList() + val relevantFileNode = relevantFileInfo.fileNode ?: return@supplyAsync emptyList() + val token = relevantFileInfo.tokens.findAt(params.position) ?: return@supplyAsync emptyList() + + val (_, files, semanticModel) = workspace.getFolderSnapshot(path) ?: return@supplyAsync emptyList() + if (semanticModel == null) return@supplyAsync emptyList() + + val globalPackage = semanticModel.global + val typeLookup = SamtDeclarationLookup.analyze( + relevantFileNode, + globalPackage.resolveSubPackage(relevantFileInfo.fileNode.packageDeclaration.name), + semanticModel.userMetadata + ) + val type = typeLookup[token.location] ?: return@supplyAsync emptyList() + + val filesAndPackages = buildList { + for (fileInfo in files) { + val fileNode: FileNode = fileInfo.fileNode ?: continue + val filePackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) + add(fileNode to filePackage) } + } - val typeReferencesLookup = SamtReferencesLookup.analyze(filesAndPackages, semanticModel.userMetadata) + val typeReferencesLookup = SamtReferencesLookup.analyze(filesAndPackages, semanticModel.userMetadata) - val references = typeReferencesLookup[type] ?: emptyList() + val references = typeReferencesLookup[type] ?: emptyList() - return@supplyAsync references.map { Location(it.source.path.toString(), it.toRange()) } - } + return@supplyAsync references.map { Location(it.source.path.toString(), it.toRange()) } + } override fun semanticTokensFull(params: SemanticTokensParams): CompletableFuture = - CompletableFuture.supplyAsync { - val path = params.textDocument.uri.toPathUri() - - val fileInfo = workspace.getFile(path) ?: return@supplyAsync SemanticTokens(emptyList()) - - val tokens: List = fileInfo.tokens - val fileNode: FileNode = fileInfo.fileNode ?: return@supplyAsync SemanticTokens(emptyList()) - val semanticModel = workspace.getSemanticModel(path) ?: return@supplyAsync SemanticTokens(emptyList()) - val filePackage = semanticModel.global.resolveSubPackage(fileNode.packageDeclaration.name) - - val semanticTokens = SamtSemanticTokens.analyze(fileNode, filePackage, semanticModel.userMetadata) - - var lastLine = 0 - var lastStartChar = 0 - - val encodedData = buildList { - for (token in tokens) { - val (tokenType, modifier) = semanticTokens[token.location] ?: continue - val (_, start, end) = token.location - val line = start.row - val deltaLine = line - lastLine - val startChar = start.col - val deltaStartChar = if (deltaLine == 0) startChar - lastStartChar else startChar - val length = end.charIndex - start.charIndex - add(deltaLine) - add(deltaStartChar) - add(length) - add(tokenType.ordinal) - add(modifier.bitmask) - lastLine = line - lastStartChar = startChar - } + CompletableFuture.supplyAsync { + val path = params.textDocument.uri.toPathUri() + + val fileInfo = workspace.getFile(path) ?: return@supplyAsync SemanticTokens(emptyList()) + + val tokens: List = fileInfo.tokens + val fileNode: FileNode = fileInfo.fileNode ?: return@supplyAsync SemanticTokens(emptyList()) + val semanticModel = workspace.getSemanticModel(path) ?: return@supplyAsync SemanticTokens(emptyList()) + val filePackage = semanticModel.global.resolveSubPackage(fileNode.packageDeclaration.name) + + val semanticTokens = SamtSemanticTokens.analyze(fileNode, filePackage, semanticModel.userMetadata) + + var lastLine = 0 + var lastStartChar = 0 + + val encodedData = buildList { + for (token in tokens) { + val (tokenType, modifier) = semanticTokens[token.location] ?: continue + val (_, start, end) = token.location + val line = start.row + val deltaLine = line - lastLine + val startChar = start.col + val deltaStartChar = if (deltaLine == 0) startChar - lastStartChar else startChar + val length = end.charIndex - start.charIndex + add(deltaLine) + add(deltaStartChar) + add(length) + add(tokenType.ordinal) + add(modifier.bitmask) + lastLine = line + lastStartChar = startChar } - - SemanticTokens(encodedData) } + SemanticTokens(encodedData) + } + override fun hover(params: HoverParams): CompletableFuture = CompletableFuture.supplyAsync { val path = params.textDocument.uri.toPathUri() @@ -185,7 +192,8 @@ class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocume } private fun UserDeclared.peekDeclaration(): String { - fun List.toParameterList(): String = joinToString(", ") { it.peekDeclaration() } + fun List.toParameterList(): String = + joinToString(", ") { it.peekDeclaration() } return when (this) { is AliasType -> "${getHumanReadableName()} $humanReadableName" @@ -212,6 +220,7 @@ class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocume raisesTypes.joinTo(this, ", ") { it.humanReadableName } } } + is ServiceType.Operation.Parameter -> "$name: ${type.humanReadableName}" is RecordType -> "${getHumanReadableName()} $humanReadableName" is ServiceType -> "${getHumanReadableName()} $humanReadableName" diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt index 691357a7..75dae1be 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt @@ -7,26 +7,29 @@ import tools.samt.semantic.SemanticModel import java.net.URI class SamtWorkspace { - private val folders = mutableMapOf() + /** + * Maps the path of the config file to the folder + */ + private val foldersByConfigPath = mutableMapOf() private val changedFolders = mutableSetOf() private val removedFiles = mutableSetOf() - fun getFolderSnapshot(path: URI): FolderSnapshot? = getFolder(path)?.let { FolderSnapshot(it.path, it.toList(), it.semanticModel) } + fun getFolderSnapshot(path: URI): FolderSnapshot? = getFolder(path)?.let { FolderSnapshot(it.sourcePath, it.toList(), it.semanticModel) } fun addFolder(folder: SamtFolder) { - val newPath = folder.path // folder is contained in other folder, ignore - if (folders.keys.any { newPath.startsWith(it) }) { + if (foldersByConfigPath.values.any { folder.sourcePath.startsWith(it.sourcePath) }) { return } // remove folders contained in new folder - folders.keys.removeIf { it.startsWith(newPath) } - folders[folder.path] = folder + foldersByConfigPath.values.removeIf { it.sourcePath.startsWith(folder.sourcePath) } + foldersByConfigPath[folder.configPath] = folder changedFolders.add(folder) + removedFiles.removeAll(folder.map { it.path }.toSet()) } - fun removeFolder(path: URI) { - folders.remove(path)?.let { folder -> + fun removeFolder(configPath: URI) { + foldersByConfigPath.remove(configPath)?.let { folder -> removedFiles.addAll(folder.map { it.path }) } } @@ -39,6 +42,7 @@ class SamtWorkspace { val folder = getFolder(file.path) ?: return folder.set(file) changedFolders.add(folder) + removedFiles.remove(file.path) } fun removeFile(path: URI) { @@ -48,6 +52,8 @@ class SamtWorkspace { removedFiles.add(path) } + fun containsFile(path: URI) = foldersByConfigPath.keys.any { path.startsWith(it) } + fun removeDirectory(path: URI) { val folder = getFolder(path) val files = folder?.getFilesIn(path)?.ifEmpty { null } ?: return @@ -73,20 +79,21 @@ class SamtWorkspace { removedFiles.clear() } - private fun getFolder(path: URI): SamtFolder? = folders[path] ?: folders.values.singleOrNull { path.startsWith(it.path) } + private fun getFolder(path: URI): SamtFolder? = + foldersByConfigPath[path] ?: foldersByConfigPath.values.singleOrNull { path.startsWith(it.sourcePath) } } -data class FolderSnapshot(val path: URI, val files: List, val semanticModel: SemanticModel?) +data class FolderSnapshot(val sourcePath: URI, val files: List, val semanticModel: SemanticModel?) fun LanguageClient.updateWorkspace(workspace: SamtWorkspace) { workspace.buildSemanticModel() workspace.getPendingMessages().forEach { (path, messages) -> publishDiagnostics( - PublishDiagnosticsParams( - path.toString(), - messages.mapNotNull { it.toDiagnostic() } - ) + PublishDiagnosticsParams( + path.toString(), + messages.mapNotNull { it.toDiagnostic() } + ) ) } workspace.clearChanges() diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspaceService.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspaceService.kt index 7498c49d..4f89c8f1 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspaceService.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspaceService.kt @@ -23,18 +23,23 @@ class SamtWorkspaceService(private val workspace: SamtWorkspace) : WorkspaceServ for (change in params.changes) { val uri = change.uri.toPathUri() val path = uri.toPath() - if (path.isDirectory()) { - when (change.type) { + val changeType = checkNotNull(change.type) + when { + path.isDirectory() -> when (changeType) { FileChangeType.Created -> parseFilesInDirectory(uri).forEach(workspace::setFile) FileChangeType.Changed -> error("Directory changes should not be watched") FileChangeType.Deleted -> workspace.removeDirectory(uri) - null -> error("Unexpected null value for change.type") } - } else if (path.extension == "samt") { - when (change.type) { + path.fileName == SAMT_CONFIG_FILE_NAME -> when (changeType) { + FileChangeType.Created, FileChangeType.Changed -> { + workspace.removeFolder(uri) + SamtFolder.fromConfig(uri)?.let { workspace.addFolder(it) } + } + FileChangeType.Deleted -> workspace.removeFolder(uri) + } + path.extension == "samt" -> when (changeType) { FileChangeType.Created, FileChangeType.Changed -> workspace.setFile(readAndParseFile(uri)) FileChangeType.Deleted -> workspace.removeFile(uri) - null -> error("Unexpected null value for change.type") } } } @@ -82,13 +87,15 @@ class SamtWorkspaceService(private val workspace: SamtWorkspace) : WorkspaceServ override fun didChangeWorkspaceFolders(params: DidChangeWorkspaceFoldersParams) { val event = params.event for (added in event.added) { - val path = added.uri.toPathUri() - val folder = SamtFolder.fromDirectory(path) - workspace.addFolder(folder) - folder.buildSemanticModel() + val path = added.uri.toPathUri().toPath() + findSamtConfigs(path).forEach { configPath -> + SamtFolder.fromConfig(configPath.toUri())?.let { workspace.addFolder(it) } + } } for (removed in event.removed) { - workspace.removeFolder(removed.uri.toPathUri()) + val path = removed.uri.toPathUri().toPath() + findSamtConfigs(path) + .forEach { workspace.removeFolder(it.toUri()) } } client.updateWorkspace(workspace) } @@ -98,7 +105,7 @@ class SamtWorkspaceService(private val workspace: SamtWorkspace) : WorkspaceServ } private fun parseFilesInDirectory(path: URI): List { - val folderPath = checkNotNull(workspace.getFolderSnapshot(path)).path + val folderPath = checkNotNull(workspace.getFolderSnapshot(path)).sourcePath val sourceFiles = collectSamtFiles(path).readSamtSource(DiagnosticController(folderPath)) return sourceFiles.map(::parseFile) } diff --git a/language-server/src/test/kotlin/tools/samt/ls/SamtConfigTest.kt b/language-server/src/test/kotlin/tools/samt/ls/SamtConfigTest.kt new file mode 100644 index 00000000..8c6c867b --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/SamtConfigTest.kt @@ -0,0 +1,45 @@ +package tools.samt.ls + +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SamtConfigTest { + private val testDirectory = Path("src/test/resources/path-test") + @BeforeTest + fun setup() { + assertTrue(testDirectory.exists() && testDirectory.isDirectory(), "Test directory does not exist") + } + + @Test + fun `no samt yaml`() { + val workDir = testDirectory.resolve("no-yaml") + val configs = findSamtConfigs(workDir) + assertEquals(emptyList(), configs) + } + + @Test + fun `samt yaml in workdir`() { + val workDir = testDirectory.resolve("yaml-in-workdir") + val configs = findSamtConfigs(workDir) + assertEquals(listOf(workDir.resolve("samt.yaml")), configs) + } + + @Test + fun `samt yaml in subdir`() { + val workDir = testDirectory.resolve("yaml-in-subdir") + val configs = findSamtConfigs(workDir) + assertEquals(listOf(workDir.resolve("sub/samt.yaml")), configs) + } + + @Test + fun `samt yaml in parent dir`() { + val workDir = testDirectory.resolve("yaml-in-parentdir/parent/work") + val configs = findSamtConfigs(workDir) + assertEquals(listOf(workDir.resolveSibling("samt.yaml")), configs) + } +} \ No newline at end of file diff --git a/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt b/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt index 82b826e3..56c10f18 100644 --- a/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt +++ b/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt @@ -1,58 +1,69 @@ package tools.samt.ls +import org.junit.jupiter.api.assertThrows import tools.samt.common.SourceFile import kotlin.test.* class SamtFolderTest { @Test fun `file can be retrieved with URI after set`() { - val workspace = SamtFolder("file:///tmp/test".toPathUri()) - val uri = "file:///tmp/test/model.samt".toPathUri() + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val uri = "file:///tmp/test/src/model.samt".toPathUri() val fileInfo = parseFile(SourceFile(uri, "package foo.bar")) - workspace.set(fileInfo) - assertSame(fileInfo, workspace[uri]) - assertTrue(uri in workspace) - assertContains(workspace as Iterable, fileInfo) + folder.set(fileInfo) + assertSame(fileInfo, folder[uri]) + assertTrue(uri in folder) + assertContains(folder as Iterable, fileInfo) } @Test fun `file cannot be retrieved after removal`() { - val workspace = SamtFolder("file:///tmp/test".toPathUri()) - val uri = "file:///tmp/test/model.samt".toPathUri() + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val uri = "file:///tmp/test/src/model.samt".toPathUri() val fileInfo = parseFile(SourceFile(uri, "package foo.bar")) - workspace.set(fileInfo) - workspace.remove(uri) - assertNull(workspace[uri]) - assertFalse(uri in workspace) - assertFalse(fileInfo in workspace) + folder.set(fileInfo) + folder.remove(uri) + assertNull(folder[uri]) + assertFalse(uri in folder) + assertFalse(fileInfo in folder) } @Test fun `getFilesIn returns files in directory`() { - val workspace = SamtFolder("file:///tmp/test".toPathUri()) - val file1 = "file:///tmp/test/dir/foo.samt".toPathUri() - val file2 = "file:///tmp/test/dir/bar.samt".toPathUri() - val file3 = "file:///tmp/test/baz.samt".toPathUri() + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val file1 = "file:///tmp/test/src/dir/foo.samt".toPathUri() + val file2 = "file:///tmp/test/src/dir/bar.samt".toPathUri() + val file3 = "file:///tmp/test/src/baz.samt".toPathUri() val fileInfo1 = parseFile(SourceFile(file1, "package foo.bar")) val fileInfo2 = parseFile(SourceFile(file2, "package foo.bar")) val fileInfo3 = parseFile(SourceFile(file3, "package foo.bar")) - workspace.set(fileInfo1) - workspace.set(fileInfo2) - workspace.set(fileInfo3) - assertEquals(setOf(file1, file2), workspace.getFilesIn("file:///tmp/test/dir".toPathUri()).toSet()) + folder.set(fileInfo1) + folder.set(fileInfo2) + folder.set(fileInfo3) + assertEquals(setOf(file1, file2), folder.getFilesIn("file:///tmp/test/src/dir".toPathUri()).toSet()) } @Test fun `getMessages includes parser and semantic messages`() { - val workspace = SamtFolder("file:///tmp/test".toPathUri()) - val uri1 = "file:///tmp/test/record.samt".toPathUri() - val uri2 = "file:///tmp/test/service.samt".toPathUri() + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val uri1 = "file:///tmp/test/src/record.samt".toPathUri() + val uri2 = "file:///tmp/test/src/service.samt".toPathUri() val fileInfo1 = parseFile(SourceFile(uri1, "package foo.bar record Foo { ")) val fileInfo2 = parseFile(SourceFile(uri2, "package foo.bar service FooService { getService(): Foo }")) - workspace.set(fileInfo1) - workspace.set(fileInfo2) - workspace.buildSemanticModel() - val messages = workspace.getAllMessages() + folder.set(fileInfo1) + folder.set(fileInfo2) + folder.buildSemanticModel() + val messages = folder.getAllMessages() assertEquals(2, messages.size) } + + @Test + fun `setting file with path outside of folder throws IllegalArgumentException`() { + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val uri = "file:///tmp/other/model.samt".toPathUri() + val fileInfo = parseFile(SourceFile(uri, "package foo.bar")) + assertThrows { + folder.set(fileInfo) + } + } } diff --git a/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt b/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt index 6ff0556c..099fe25b 100644 --- a/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt +++ b/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt @@ -7,9 +7,9 @@ import kotlin.test.* class SamtWorkspaceTest { @Test fun `getFile retrieves file`() { - val folder = SamtFolder("file:///tmp/test".toPathUri()) + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) val workspace = SamtWorkspace() - val uri = "file:///tmp/test/model.samt".toPathUri() + val uri = "file:///tmp/test/src/model.samt".toPathUri() workspace.addFolder(folder) val fileInfo = parseFile(SourceFile (uri, "package foo.bar")) folder.set(fileInfo) @@ -18,43 +18,43 @@ class SamtWorkspaceTest { @Test fun `file is in folder snapshot`() { - val folder = SamtFolder("file:///tmp/test".toPathUri()) + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) val workspace = SamtWorkspace() - val uri = "file:///tmp/test/model.samt".toPathUri() + val uri = "file:///tmp/test/src/model.samt".toPathUri() workspace.addFolder(folder) val fileInfo = parseFile(SourceFile (uri, "package foo.bar")) folder.set(fileInfo) - val snapshot = workspace.getFolderSnapshot("file:///tmp/test".toPathUri()) + val snapshot = workspace.getFolderSnapshot("file:///tmp/test/src".toPathUri()) assertEquals(listOf(fileInfo), snapshot?.files) } @Test fun `folder which is already contained in other folder is ignored`() { - val outer = SamtFolder("file:///tmp/test".toPathUri()) - val inner = SamtFolder("file:///tmp/test/inner".toPathUri()) + val outer = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val inner = SamtFolder("file:///tmp/test/src/inner/samt.yaml".toPathUri(), "file:///tmp/test/src/inner/src".toPathUri()) val workspace = SamtWorkspace() workspace.addFolder(outer) workspace.addFolder(inner) - val snapshot = workspace.getFolderSnapshot("file:///tmp/test/inner".toPathUri()) - assertEquals(outer.path, snapshot?.path) + val snapshot = workspace.getFolderSnapshot("file:///tmp/test/src/inner/src".toPathUri()) + assertEquals(outer.sourcePath, snapshot?.sourcePath) } @Test fun `folder which contains other folder overwrites it`() { - val inner = SamtFolder("file:///tmp/test/inner".toPathUri()) - val outer = SamtFolder("file:///tmp/test".toPathUri()) + val inner = SamtFolder("file:///tmp/test/src/inner/samt.yaml".toPathUri(), "file:///tmp/test/src/inner/src".toPathUri()) + val outer = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) val workspace = SamtWorkspace() workspace.addFolder(inner) workspace.addFolder(outer) - val snapshot = workspace.getFolderSnapshot("file:///tmp/test/inner".toPathUri()) - assertEquals(outer.path, snapshot?.path) + val snapshot = workspace.getFolderSnapshot("file:///tmp/test/src/inner/src".toPathUri()) + assertEquals(outer.sourcePath, snapshot?.sourcePath) } @Test fun `getPendingMessages includes messages from new files`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val uri = "file:///tmp/test/model.samt".toPathUri() + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val uri = "file:///tmp/test/src/model.samt".toPathUri() val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) workspace.setFile(fileInfo) val messages = workspace.getPendingMessages()[uri] @@ -64,8 +64,8 @@ class SamtWorkspaceTest { @Test fun `getPendingMessages includes emptyList for removed file`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val uri = "file:///tmp/test/model.samt".toPathUri() + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val uri = "file:///tmp/test/src/model.samt".toPathUri() val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) workspace.setFile(fileInfo) workspace.removeFile(uri) @@ -76,12 +76,12 @@ class SamtWorkspaceTest { @Test fun `getPendingMessages includes empty list for every file in removed folder`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val file1 = parseFile(SourceFile ("file:///tmp/test/foo.samt".toPathUri(), "package foo.bar record Foo {")) - val file2 = parseFile(SourceFile ("file:///tmp/test/bar.samt".toPathUri(), "package foo.bar record Bar {")) + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val file1 = parseFile(SourceFile ("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {")) + val file2 = parseFile(SourceFile ("file:///tmp/test/src/bar.samt".toPathUri(), "package foo.bar record Bar {")) workspace.setFile(file1) workspace.setFile(file2) - workspace.removeFolder("file:///tmp/test".toPathUri()) + workspace.removeFolder("file:///tmp/test/samt.yaml".toPathUri()) val messages = workspace.getPendingMessages() assertEquals(mapOf(file1.path to emptyList(), file2.path to emptyList()), messages) } @@ -89,7 +89,7 @@ class SamtWorkspaceTest { @Test fun `getPendingMessages is empty after clearing changes`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) val uri = "file:///tmp/test/model.samt".toPathUri() val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) workspace.setFile(fileInfo) @@ -101,9 +101,9 @@ class SamtWorkspaceTest { @Test fun `removing file triggers semantic error in dependent file`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val file1 = parseFile(SourceFile ("file:///tmp/test/foo.samt".toPathUri(), "package foo.bar record Foo {}")) - val file2 = parseFile(SourceFile ("file:///tmp/test/bar.samt".toPathUri(), "package foo.bar record Bar { foo: Foo }")) + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val file1 = parseFile(SourceFile ("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {}")) + val file2 = parseFile(SourceFile ("file:///tmp/test/src/bar.samt".toPathUri(), "package foo.bar record Bar { foo: Foo }")) workspace.setFile(file1) workspace.setFile(file2) workspace.buildSemanticModel() @@ -122,12 +122,12 @@ class SamtWorkspaceTest { @Test fun `getPendingMessages includes empty list for every file in removed directory`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val file1 = parseFile(SourceFile ("file:///tmp/test/subfolder/foo.samt".toPathUri(), "package foo.bar record Foo {")) - val file2 = parseFile(SourceFile ("file:///tmp/test/subfolder/bar.samt".toPathUri(), "package foo.bar record Bar {")) + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val file1 = parseFile(SourceFile ("file:///tmp/test/src/subfolder/foo.samt".toPathUri(), "package foo.bar record Foo {")) + val file2 = parseFile(SourceFile ("file:///tmp/test/src/subfolder/bar.samt".toPathUri(), "package foo.bar record Bar {")) workspace.setFile(file1) workspace.setFile(file2) - workspace.removeDirectory("file:///tmp/test/subfolder".toPathUri()) + workspace.removeDirectory("file:///tmp/test/src/subfolder".toPathUri()) val messages = workspace.getPendingMessages() assertEquals(mapOf(file1.path to emptyList(), file2.path to emptyList()), messages) } @@ -135,8 +135,8 @@ class SamtWorkspaceTest { @Test fun `semanticModel can be found after buildSemanticModel`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val file1 = parseFile(SourceFile ("file:///tmp/test/foo.samt".toPathUri(), "package foo.bar record Foo {}")) + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val file1 = parseFile(SourceFile ("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {}")) workspace.setFile(file1) assertNull(workspace.getSemanticModel(file1.path)) workspace.buildSemanticModel() @@ -147,12 +147,45 @@ class SamtWorkspaceTest { @Test fun `if file has not changed pending messages don't change`() { val workspace = SamtWorkspace() - workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) - val sourceFile = SourceFile("file:///tmp/test/foo.samt".toPathUri(), "package foo.bar record Foo {") + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + val sourceFile = SourceFile("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {") workspace.setFile(parseFile(sourceFile)) assertEquals(1, workspace.getPendingMessages().size) workspace.clearChanges() workspace.setFile(parseFile(sourceFile)) assertEquals(emptyMap(), workspace.getPendingMessages()) } + + @Test + fun `if file is removed and re-added pending messages are not empty`() { + val workspace = SamtWorkspace() + val sourceFile = SourceFile("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {") + workspace.addFolder(SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri())) + + workspace.setFile(parseFile(sourceFile)) + assertEquals(DiagnosticSeverity.Error, workspace.getPendingMessages()[sourceFile.path]?.single()?.severity) + + workspace.removeFile(sourceFile.path) + assertEquals(mapOf(sourceFile.path to emptyList()), workspace.getPendingMessages()) + + workspace.setFile(parseFile(sourceFile)) + assertEquals(DiagnosticSeverity.Error, workspace.getPendingMessages()[sourceFile.path]?.single()?.severity) + } + + @Test + fun `if folder is removed and re-added pending messages are not empty`() { + val workspace = SamtWorkspace() + val folder = SamtFolder("file:///tmp/test/samt.yaml".toPathUri(), "file:///tmp/test/src".toPathUri()) + val sourceFile = SourceFile("file:///tmp/test/src/foo.samt".toPathUri(), "package foo.bar record Foo {") + workspace.addFolder(folder) + + workspace.setFile(parseFile(sourceFile)) + assertEquals(DiagnosticSeverity.Error, workspace.getPendingMessages()[sourceFile.path]?.single()?.severity) + + workspace.removeFolder(folder.configPath) + assertEquals(mapOf(sourceFile.path to emptyList()), workspace.getPendingMessages()) + + workspace.addFolder(folder) + assertEquals(DiagnosticSeverity.Error, workspace.getPendingMessages()[sourceFile.path]?.single()?.severity) + } } diff --git a/language-server/src/test/resources/path-test/yaml-in-parentdir/parent/samt.yaml b/language-server/src/test/resources/path-test/yaml-in-parentdir/parent/samt.yaml new file mode 100644 index 00000000..e0fa9dec --- /dev/null +++ b/language-server/src/test/resources/path-test/yaml-in-parentdir/parent/samt.yaml @@ -0,0 +1 @@ +source: ./work/src \ No newline at end of file diff --git a/language-server/src/test/resources/path-test/yaml-in-subdir/sub/samt.yaml b/language-server/src/test/resources/path-test/yaml-in-subdir/sub/samt.yaml new file mode 100644 index 00000000..b2b56b0d --- /dev/null +++ b/language-server/src/test/resources/path-test/yaml-in-subdir/sub/samt.yaml @@ -0,0 +1 @@ +source: ./src \ No newline at end of file diff --git a/language-server/src/test/resources/path-test/yaml-in-workdir/samt.yaml b/language-server/src/test/resources/path-test/yaml-in-workdir/samt.yaml new file mode 100644 index 00000000..b2b56b0d --- /dev/null +++ b/language-server/src/test/resources/path-test/yaml-in-workdir/samt.yaml @@ -0,0 +1 @@ +source: ./src \ No newline at end of file diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt index 78aa56a1..cb2d1720 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SamtConfiguration( +internal data class SamtConfiguration( val source: String = "./src", val repositories: SamtRepositoriesConfiguration = SamtRepositoriesConfiguration(), val plugins: List = emptyList(), @@ -12,22 +12,22 @@ data class SamtConfiguration( ) @Serializable -data class SamtRepositoriesConfiguration( +internal data class SamtRepositoriesConfiguration( val maven: String = "https://repo.maven.apache.org/maven2" ) @Serializable -sealed interface SamtPluginConfiguration +internal sealed interface SamtPluginConfiguration @Serializable @SerialName("local") -data class SamtLocalPluginConfiguration( +internal data class SamtLocalPluginConfiguration( val path: String, ) : SamtPluginConfiguration @Serializable @SerialName("maven") -data class SamtMavenPluginConfiguration( +internal data class SamtMavenPluginConfiguration( val groupId: String, val artifactId: String, val version: String, @@ -36,13 +36,13 @@ data class SamtMavenPluginConfiguration( @Serializable @SerialName("gradle") -data class SamtGradlePluginConfiguration( +internal data class SamtGradlePluginConfiguration( val dependency: String, val repository: String? = null, ) : SamtPluginConfiguration @Serializable -data class SamtGeneratorConfiguration( +internal data class SamtGeneratorConfiguration( val name: String, val output: String = "./out", val options: Map = emptyMap(), diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt index 3c64f55b..7f3ec9c9 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt @@ -1,6 +1,7 @@ package tools.samt.config import com.charleskorn.kaml.* +import kotlinx.serialization.SerializationException import java.nio.file.Path import kotlin.io.path.exists import kotlin.io.path.inputStream @@ -23,9 +24,15 @@ object SamtConfigurationParser { ) ) + class ParseException(exception: Throwable) : RuntimeException(exception.message, exception) + fun parseConfiguration(path: Path): CommonSamtConfiguration { val parsedConfiguration: SamtConfiguration = if (path.exists()) { - yaml.decodeFromStream(path.inputStream()) + try { + yaml.decodeFromStream(path.inputStream()) + } catch (exception: SerializationException) { + throw ParseException(exception) + } } else { SamtConfiguration() } diff --git a/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt b/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt index 847e0954..517d2b4c 100644 --- a/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt +++ b/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt @@ -85,7 +85,7 @@ class SamtConfigurationParserTest { @Test fun `throws for samt-invalid file`() { - val exception = assertThrows { + val exception = assertThrows { SamtConfigurationParser.parseConfiguration(testDirectory.resolve("samt-invalid.yaml")) }