diff --git a/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt b/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt index 41e34e08..8c1d08c8 100644 --- a/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt +++ b/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt @@ -31,11 +31,19 @@ class ASTPrinterTest { @Description("This is a service") service MyService { testmethod(foo: A): B + oneway testmethod2(foo: A) } provide MyEndpoint { implements MyService - transport HTTP + transport HTTP { + operations: { + MyService: { + testmethod: "POST /testmethod", + testmethod2: "POST /testmethod2" + } + } + } } consume MyEndpoint { @@ -105,22 +113,41 @@ class ASTPrinterTest { │ │ │ └─IdentifierNode A <17:19> │ │ └─BundleIdentifierNode B <17:23> │ │ └─IdentifierNode B <17:23> + │ ├─OnewayOperationNode <18:3> + │ │ ├─IdentifierNode testmethod2 <18:10> + │ │ └─OperationParameterNode <18:22> + │ │ ├─IdentifierNode foo <18:22> + │ │ └─BundleIdentifierNode A <18:27> + │ │ └─IdentifierNode A <18:27> │ └─AnnotationNode <15:1> │ ├─IdentifierNode Description <15:2> │ └─StringNode "This is a service" <15:14> - ├─ProviderDeclarationNode <20:1> - │ ├─IdentifierNode MyEndpoint <20:9> - │ ├─ProviderImplementsNode <21:3> - │ │ └─BundleIdentifierNode MyService <21:14> - │ │ └─IdentifierNode MyService <21:14> - │ └─ProviderTransportNode <22:3> - │ └─IdentifierNode HTTP <22:13> - └─ConsumerDeclarationNode <25:1> - ├─BundleIdentifierNode MyEndpoint <25:9> - │ └─IdentifierNode MyEndpoint <25:9> - └─ConsumerUsesNode <26:3> - └─BundleIdentifierNode MyService <26:8> - └─IdentifierNode MyService <26:8> + ├─ProviderDeclarationNode <21:1> + │ ├─IdentifierNode MyEndpoint <21:9> + │ ├─ProviderImplementsNode <22:3> + │ │ └─BundleIdentifierNode MyService <22:14> + │ │ └─IdentifierNode MyService <22:14> + │ └─ProviderTransportNode <23:3> + │ ├─IdentifierNode HTTP <23:13> + │ └─ObjectNode <23:18> + │ └─ObjectFieldNode <24:5> + │ ├─IdentifierNode operations <24:5> + │ └─ObjectNode <24:17> + │ └─ObjectFieldNode <25:9> + │ ├─IdentifierNode MyService <25:9> + │ └─ObjectNode <25:20> + │ ├─ObjectFieldNode <26:13> + │ │ ├─IdentifierNode testmethod <26:13> + │ │ └─StringNode "POST /testmethod" <26:25> + │ └─ObjectFieldNode <27:13> + │ ├─IdentifierNode testmethod2 <27:13> + │ └─StringNode "POST /testmethod2" <27:26> + └─ConsumerDeclarationNode <33:1> + ├─BundleIdentifierNode MyEndpoint <33:9> + │ └─IdentifierNode MyEndpoint <33:9> + └─ConsumerUsesNode <34:3> + └─BundleIdentifierNode MyService <34:8> + └─IdentifierNode MyService <34:8> """.trimIndent().trim(), dumpWithoutColorCodes.trimIndent().trim()) } diff --git a/cli/src/test/kotlin/tools/samt/cli/TypePrinterTest.kt b/cli/src/test/kotlin/tools/samt/cli/TypePrinterTest.kt index 56dd9560..57faba60 100644 --- a/cli/src/test/kotlin/tools/samt/cli/TypePrinterTest.kt +++ b/cli/src/test/kotlin/tools/samt/cli/TypePrinterTest.kt @@ -13,7 +13,7 @@ import kotlin.test.assertFalse class TypePrinterTest { @Test - fun `correctly formats an AST dump`() { + fun `correctly formats a type dump`() { val stuffPackage = parse(""" package test.stuff @@ -33,6 +33,8 @@ class TypePrinterTest { implements MyService transport HTTP } + + typealias F = E """.trimIndent()) val consumerPackage = parse(""" package test.other.company @@ -57,6 +59,7 @@ class TypePrinterTest { ├─stuff │ enum E │ record A + │ typealias F = E │ service MyService │ provider MyEndpoint └─other diff --git a/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt b/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt index 293866d8..3b2e8664 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt @@ -7,18 +7,28 @@ import tools.samt.lexer.Lexer import tools.samt.lexer.Token import tools.samt.parser.FileNode import tools.samt.parser.Parser +import java.net.URI +import kotlin.io.path.readText +import kotlin.io.path.toPath class FileInfo( - val diagnosticContext: DiagnosticContext, - val sourceFile: SourceFile, - @Suppress("unused") val tokens: List, - val fileNode: FileNode? = null, + val diagnosticContext: DiagnosticContext, + val sourceFile: SourceFile, + val tokens: List = emptyList(), + val fileNode: FileNode? = null, ) +val FileInfo.path get() = sourceFile.path +val FileInfo.content get() = sourceFile.content + fun parseFile(sourceFile: SourceFile): FileInfo { val diagnosticContext = DiagnosticContext(sourceFile) - val tokens = Lexer.scan(sourceFile.content.reader(), diagnosticContext).toList() + val tokens = try { + Lexer.scan(sourceFile.content.reader(), diagnosticContext).toList() + } catch (e: DiagnosticException) { + return FileInfo(diagnosticContext, sourceFile) + } if (diagnosticContext.hasErrors()) { return FileInfo(diagnosticContext, sourceFile, tokens) @@ -33,3 +43,8 @@ fun parseFile(sourceFile: SourceFile): FileInfo { return FileInfo(diagnosticContext, sourceFile, tokens, fileNode) } + +fun readAndParseFile(uri: URI): FileInfo { + val sourceFile = SourceFile(uri, uri.toPath().readText()) + return parseFile(sourceFile) +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt b/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt index 4de60438..00431c71 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt @@ -9,11 +9,7 @@ fun DiagnosticMessage.toDiagnostic(): Diagnostic? { val diagnostic = Diagnostic() val primaryHighlight = this.highlights.firstOrNull() ?: return null diagnostic.range = primaryHighlight.location.toRange() - diagnostic.severity = when (severity) { - DiagnosticSeverity.Error -> org.eclipse.lsp4j.DiagnosticSeverity.Error - DiagnosticSeverity.Warning -> org.eclipse.lsp4j.DiagnosticSeverity.Warning - DiagnosticSeverity.Info -> org.eclipse.lsp4j.DiagnosticSeverity.Information - } + diagnostic.severity = severity.toLspSeverity() diagnostic.source = "samt" diagnostic.message = message diagnostic.relatedInformation = highlights.filter { it.message != null }.map { @@ -25,9 +21,13 @@ fun DiagnosticMessage.toDiagnostic(): Diagnostic? { return diagnostic } -fun SamtLocation.toRange(): Range { - return Range( - Position(start.row, start.col), - Position(end.row, end.col) - ) +fun DiagnosticSeverity.toLspSeverity(): org.eclipse.lsp4j.DiagnosticSeverity = when (this) { + DiagnosticSeverity.Error -> org.eclipse.lsp4j.DiagnosticSeverity.Error + DiagnosticSeverity.Warning -> org.eclipse.lsp4j.DiagnosticSeverity.Warning + DiagnosticSeverity.Info -> org.eclipse.lsp4j.DiagnosticSeverity.Information } + +fun SamtLocation.toRange(): Range = Range( + Position(start.row, start.col), + Position(end.row, end.col) +) diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt new file mode 100644 index 00000000..8fae56c0 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtFolder.kt @@ -0,0 +1,61 @@ +package tools.samt.ls + +import tools.samt.common.DiagnosticController +import tools.samt.common.DiagnosticMessage +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource +import tools.samt.semantic.Package +import tools.samt.semantic.SemanticModelBuilder +import java.net.URI + +class SamtFolder(val path: URI) : Iterable { + private val files = mutableMapOf() + var globalPackage: Package? = null + private set + private var semanticController: DiagnosticController = DiagnosticController(path) + + fun set(fileInfo: FileInfo) { + files[fileInfo.sourceFile.path] = fileInfo + } + + fun remove(fileUri: URI) { + files.remove(fileUri) + } + + operator fun get(path: URI): FileInfo? = files[path] + + override fun iterator(): Iterator = files.values.iterator() + + operator fun contains(path: URI) = path in files + + fun getFilesIn(directoryPath: URI): List { + return files.keys.filter { it.startsWith(directoryPath) } + } + + fun buildSemanticModel() { + semanticController = DiagnosticController(path) + globalPackage = SemanticModelBuilder.build(mapNotNull { it.fileNode }, semanticController) + } + + private fun getMessages(path: URI): List { + val fileInfo = files[path] ?: return emptyList() + return fileInfo.diagnosticContext.messages + + semanticController.getOrCreateContext(fileInfo.sourceFile).messages + } + + fun getAllMessages() = files.keys.associateWith { + getMessages(it) + } + + 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)) + } + return workspace + } + } +} 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 a59a5f73..f1142e89 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt @@ -3,11 +3,8 @@ package tools.samt.ls import org.eclipse.lsp4j.* import org.eclipse.lsp4j.jsonrpc.messages.Either import org.eclipse.lsp4j.services.* -import tools.samt.common.DiagnosticController -import tools.samt.common.collectSamtFiles -import tools.samt.common.readSamtSource import java.io.Closeable -import java.net.URI +import java.util.* import java.util.concurrent.CompletableFuture import java.util.logging.Logger import kotlin.system.exitProcess @@ -15,8 +12,9 @@ import kotlin.system.exitProcess class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { private lateinit var client: LanguageClient private val logger = Logger.getLogger("SamtLanguageServer") - private val workspaces = mutableMapOf() - private val textDocumentService = SamtTextDocumentService(workspaces) + private val workspace = SamtWorkspace() + private val textDocumentService = SamtTextDocumentService(workspace) + private val workspaceService = SamtWorkspaceService(workspace) override fun initialize(params: InitializeParams): CompletableFuture = CompletableFuture.supplyAsync { @@ -28,6 +26,35 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { range = Either.forLeft(false) full = Either.forLeft(true) } + workspace = WorkspaceServerCapabilities().apply { + workspaceFolders = WorkspaceFoldersOptions().apply { + supported = true + changeNotifications = Either.forRight(true) + } + fileOperations = FileOperationsServerCapabilities().apply { + val samtFilter = FileOperationFilter().apply { + pattern = FileOperationPattern().apply { + glob = "**/*.samt" + matches = FileOperationPatternKind.File + } + } + val folderFilter = FileOperationFilter().apply { + pattern = FileOperationPattern().apply { + glob = "**" + matches = FileOperationPatternKind.Folder + } + } + didCreate = FileOperationOptions().apply { + filters = listOf(samtFilter) + } + FileOperationOptions().apply { + filters = listOf(samtFilter, folderFilter) + }.let { + didRename = it + didDelete = it + } + } + } definitionProvider = Either.forLeft(true) referencesProvider = Either.forLeft(true) } @@ -35,7 +62,8 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { } override fun initialized(params: InitializedParams) { - pushDiagnostics() + registerFileWatchCapability() + client.updateWorkspace(workspace) } override fun shutdown(): CompletableFuture = CompletableFuture.completedFuture(null) @@ -46,11 +74,12 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { override fun getTextDocumentService(): TextDocumentService = textDocumentService - override fun getWorkspaceService(): WorkspaceService? = null + override fun getWorkspaceService(): WorkspaceService = workspaceService override fun connect(client: LanguageClient) { this.client = client textDocumentService.connect(client) + workspaceService.connect(client) logger.info("Connected to client") } @@ -59,31 +88,26 @@ class SamtLanguageServer : LanguageServer, LanguageClientAware, Closeable { } private fun buildSamtModel(params: InitializeParams) { - val folders = params.workspaceFolders.map { it.uri.toPathUri() } + val folders = params.workspaceFolders?.map { it.uri.toPathUri() }.orEmpty() for (folder in folders) { - // if the folder is contained within another folder ignore it - if (folders.any { folder != it && folder.path.startsWith(it.path) }) continue - workspaces[folder] = buildWorkspace(folder) + workspace.addFolder(SamtFolder.fromDirectory(folder)) } } - private fun buildWorkspace(workspacePath: URI): SamtWorkspace { - val diagnosticController = DiagnosticController(workspacePath) - val sourceFiles = collectSamtFiles(workspacePath).readSamtSource(diagnosticController) - val workspace = SamtWorkspace(diagnosticController) - sourceFiles.asSequence().map(::parseFile).forEach(workspace::add) - workspace.buildSemanticModel() - return workspace - } - - private fun pushDiagnostics() { - workspaces.values.flatMap { workspace -> - workspace.getAllMessages().map { (path, messages) -> - PublishDiagnosticsParams( - path.toString(), - messages.map { it.toDiagnostic() } + private fun registerFileWatchCapability() { + val capability = "workspace/didChangeWatchedFiles" + client.registerCapability(RegistrationParams(listOf( + Registration(UUID.randomUUID().toString(), capability, DidChangeWatchedFilesRegistrationOptions().apply { + watchers = listOf( + FileSystemWatcher().apply { + globPattern = Either.forLeft("**/*.samt") + }, + FileSystemWatcher().apply { + globPattern = Either.forLeft("**/") + kind = WatchKind.Create or WatchKind.Delete + }, ) - } - }.forEach(client::publishDiagnostics) + }) + ))) } } 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 8addda78..11204bda 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt @@ -11,17 +11,21 @@ import tools.samt.parser.FileNode import tools.samt.parser.NamedDeclarationNode import tools.samt.parser.OperationNode import tools.samt.semantic.Package -import java.net.URI import java.util.concurrent.CompletableFuture import java.util.logging.Logger -class SamtTextDocumentService(private val workspaces: Map) : TextDocumentService, +class SamtTextDocumentService(private val workspace: SamtWorkspace) : TextDocumentService, LanguageClientAware { private lateinit var client: LanguageClient private val logger = Logger.getLogger("SamtTextDocumentService") override fun didOpen(params: DidOpenTextDocumentParams) { logger.info("Opened document ${params.textDocument.uri}") + val path = params.textDocument.uri.toPathUri() + val text = params.textDocument.text + + workspace.setFile(parseFile(SourceFile(path, text))) + client.updateWorkspace(workspace) } override fun didChange(params: DidChangeTextDocumentParams) { @@ -30,23 +34,17 @@ class SamtTextDocumentService(private val workspaces: Map) : val path = params.textDocument.uri.toPathUri() val newText = params.contentChanges.single().text val fileInfo = parseFile(SourceFile(path, newText)) - val workspace = getWorkspace(path) ?: return - - workspace.add(fileInfo) - workspace.buildSemanticModel() - workspace.getAllMessages().forEach { (path, messages) -> - client.publishDiagnostics( - PublishDiagnosticsParams( - path.toString(), - messages.map { it.toDiagnostic() }, - params.textDocument.version - ) - ) - } + + workspace.setFile(fileInfo) + client.updateWorkspace(workspace) } override fun didClose(params: DidCloseTextDocumentParams) { logger.info("Closed document ${params.textDocument.uri}") + val path = params.textDocument.uri.toPathUri() + + workspace.setFile(readAndParseFile(path)) + client.updateWorkspace(workspace) } override fun didSave(params: DidSaveTextDocumentParams) { @@ -56,12 +54,11 @@ class SamtTextDocumentService(private val workspaces: Map) : override fun definition(params: DefinitionParams): CompletableFuture, List>> = CompletableFuture.supplyAsync { val path = params.textDocument.uri.toPathUri() - val workspace = getWorkspace(path) - val fileInfo = workspace?.get(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 globalPackage: Package = workspace.samtPackage ?: return@supplyAsync Either.forRight(emptyList()) + val globalPackage: Package = workspace.getRootPackage(path) ?: return@supplyAsync Either.forRight(emptyList()) val token = fileInfo.tokens.findAt(params.position) ?: return@supplyAsync Either.forRight(emptyList()) @@ -89,19 +86,19 @@ class SamtTextDocumentService(private val workspaces: Map) : override fun references(params: ReferenceParams): CompletableFuture> = CompletableFuture.supplyAsync { val path = params.textDocument.uri.toPathUri() - val workspace = getWorkspace(path) ?: return@supplyAsync emptyList() - val relevantFileInfo = workspace[path] ?: return@supplyAsync emptyList() + 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 globalPackage: Package = workspace.samtPackage ?: return@supplyAsync emptyList() + val (_, files, globalPackage) = workspace.getFolderSnapshot(path) ?: return@supplyAsync emptyList() + if (globalPackage == null) return@supplyAsync emptyList() val typeLookup = SamtDeclarationLookup.analyze(relevantFileNode, globalPackage.resolveSubPackage(relevantFileInfo.fileNode.packageDeclaration.name)) val type = typeLookup[token.location] ?: return@supplyAsync emptyList() val filesAndPackages = buildList { - for (fileInfo in workspace) { + for (fileInfo in files) { val fileNode: FileNode = fileInfo.fileNode ?: continue val samtPackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) add(fileNode to samtPackage) @@ -118,13 +115,12 @@ class SamtTextDocumentService(private val workspaces: Map) : override fun semanticTokensFull(params: SemanticTokensParams): CompletableFuture = CompletableFuture.supplyAsync { val path = params.textDocument.uri.toPathUri() - val workspace = getWorkspace(path) - val fileInfo = workspace?.get(path) ?: return@supplyAsync SemanticTokens(emptyList()) + val fileInfo = workspace.getFile(path) ?: return@supplyAsync SemanticTokens(emptyList()) val tokens: List = fileInfo.tokens val fileNode: FileNode = fileInfo.fileNode ?: return@supplyAsync SemanticTokens(emptyList()) - val globalPackage: Package = workspace.samtPackage ?: return@supplyAsync SemanticTokens(emptyList()) + val globalPackage: Package = workspace.getRootPackage(path) ?: return@supplyAsync SemanticTokens(emptyList()) val samtPackage = globalPackage.resolveSubPackage(fileNode.packageDeclaration.name) val semanticTokens = SamtSemanticTokens.analyze(fileNode, samtPackage) @@ -157,7 +153,4 @@ class SamtTextDocumentService(private val workspaces: Map) : override fun connect(client: LanguageClient) { this.client = client } - - private fun getWorkspace(filePath: URI): SamtWorkspace? = - workspaces.values.singleOrNull { filePath in it } } 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 7a181f80..d24c26cc 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt @@ -1,40 +1,93 @@ package tools.samt.ls -import tools.samt.common.DiagnosticController +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.services.LanguageClient import tools.samt.common.DiagnosticMessage import tools.samt.semantic.Package -import tools.samt.semantic.SemanticModelBuilder import java.net.URI -class SamtWorkspace(private val parserController: DiagnosticController) : Iterable { - private val files = mutableMapOf() - var samtPackage: Package? = null - private set - private var semanticController: DiagnosticController = - DiagnosticController(parserController.workingDirectory) +class SamtWorkspace { + private val folders = mutableMapOf() + private val changedFolders = mutableSetOf() + private val removedFiles = mutableSetOf() - fun add(fileInfo: FileInfo) { - files[fileInfo.sourceFile.path] = fileInfo + fun getFolderSnapshot(path: URI): FolderSnapshot? = getFolder(path)?.let { FolderSnapshot(it.path, it.toList(), it.globalPackage) } + + fun addFolder(folder: SamtFolder) { + val newPath = folder.path + // folder is contained in other folder, ignore + if (folders.keys.any { newPath.startsWith(it) }) { + return + } + // remove folders contained in new folder + folders.keys.removeIf { it.startsWith(newPath) } + folders[folder.path] = folder + changedFolders.add(folder) + } + + fun removeFolder(path: URI) { + folders.remove(path)?.let { folder -> + removedFiles.addAll(folder.map { it.path }) + } } - operator fun get(path: URI): FileInfo? = files[path] + fun getFile(path: URI): FileInfo? = getFolder(path)?.get(path) - override fun iterator(): Iterator = files.values.iterator() + fun setFile(file: FileInfo) { + val currentFile = getFile(file.path) + if (file.content == currentFile?.content) return + val folder = getFolder(file.path) ?: return + folder.set(file) + changedFolders.add(folder) + } - operator fun contains(path: URI) = path in files + fun removeFile(path: URI) { + val folder = getFolder(path) ?: return + folder.remove(path) + changedFolders.add(folder) + removedFiles.add(path) + } + + fun removeDirectory(path: URI) { + val folder = getFolder(path) + val files = folder?.getFilesIn(path)?.ifEmpty { null } ?: return + changedFolders.add(folder) + for (file in files) { + folder.remove(file) + removedFiles.add(file) + } + } + + fun getRootPackage(path: URI): Package? = getFolder(path)?.globalPackage + + fun getPendingMessages(): Map> = changedFolders.flatMap { folder -> + folder.getAllMessages().toList() + }.toMap() + removedFiles.associateWith { emptyList() } fun buildSemanticModel() { - semanticController = DiagnosticController(parserController.workingDirectory) - samtPackage = SemanticModelBuilder.build(mapNotNull { it.fileNode }, semanticController) + changedFolders.forEach { it.buildSemanticModel() } } - private fun getMessages(path: URI): List { - val fileInfo = files[path] ?: return emptyList() - return fileInfo.diagnosticContext.messages + - semanticController.getOrCreateContext(fileInfo.sourceFile).messages + fun clearChanges() { + changedFolders.clear() + removedFiles.clear() } - fun getAllMessages() = files.keys.associateWith { - getMessages(it) + private fun getFolder(path: URI): SamtFolder? = folders[path] ?: folders.values.singleOrNull { path.startsWith(it.path) } +} + +data class FolderSnapshot(val path: URI, val files: List, val globalPackage: Package?) + + +fun LanguageClient.updateWorkspace(workspace: SamtWorkspace) { + workspace.buildSemanticModel() + workspace.getPendingMessages().forEach { (path, messages) -> + publishDiagnostics( + 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 new file mode 100644 index 00000000..7498c49d --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspaceService.kt @@ -0,0 +1,105 @@ +package tools.samt.ls + +import org.eclipse.lsp4j.* +import org.eclipse.lsp4j.services.LanguageClient +import org.eclipse.lsp4j.services.LanguageClientAware +import org.eclipse.lsp4j.services.WorkspaceService +import tools.samt.common.DiagnosticController +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource +import java.net.URI +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.toPath + +class SamtWorkspaceService(private val workspace: SamtWorkspace) : WorkspaceService, LanguageClientAware { + private lateinit var client: LanguageClient + + override fun didChangeConfiguration(params: DidChangeConfigurationParams?) { + TODO("Not yet implemented") + } + + override fun didChangeWatchedFiles(params: DidChangeWatchedFilesParams) { + for (change in params.changes) { + val uri = change.uri.toPathUri() + val path = uri.toPath() + if (path.isDirectory()) { + when (change.type) { + 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) { + FileChangeType.Created, FileChangeType.Changed -> workspace.setFile(readAndParseFile(uri)) + FileChangeType.Deleted -> workspace.removeFile(uri) + null -> error("Unexpected null value for change.type") + } + } + } + client.updateWorkspace(workspace) + } + + override fun didCreateFiles(params: CreateFilesParams) { + for (file in params.files) { + val path = file.uri.toPathUri() + workspace.setFile(readAndParseFile(path)) + } + client.updateWorkspace(workspace) + } + + override fun didRenameFiles(params: RenameFilesParams) { + for (file in params.files) { + val oldUri = file.oldUri.toPathUri() + val newUri = file.newUri.toPathUri() + val newPath = newUri.toPath() + if (newPath.isDirectory()) { + workspace.removeDirectory(oldUri) + parseFilesInDirectory(newUri).forEach(workspace::setFile) + } else { + workspace.removeFile(oldUri) + if (newPath.extension == "samt") { + workspace.setFile(readAndParseFile(newUri)) + } + } + } + client.updateWorkspace(workspace) + } + + override fun didDeleteFiles(params: DeleteFilesParams) { + for (file in params.files) { + val path = file.uri.toPathUri() + if (path.toPath().isDirectory()) { + workspace.removeDirectory(path) + } else { + workspace.removeFile(path) + } + } + client.updateWorkspace(workspace) + } + + 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() + } + for (removed in event.removed) { + workspace.removeFolder(removed.uri.toPathUri()) + } + client.updateWorkspace(workspace) + } + + override fun connect(client: LanguageClient) { + this.client = client + } + + private fun parseFilesInDirectory(path: URI): List { + val folderPath = checkNotNull(workspace.getFolderSnapshot(path)).path + val sourceFiles = collectSamtFiles(path).readSamtSource(DiagnosticController(folderPath)) + return sourceFiles.map(::parseFile) + } +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/Uri.kt b/language-server/src/main/kotlin/tools/samt/ls/Uri.kt index e00398c3..b91a6218 100644 --- a/language-server/src/main/kotlin/tools/samt/ls/Uri.kt +++ b/language-server/src/main/kotlin/tools/samt/ls/Uri.kt @@ -3,4 +3,6 @@ package tools.samt.ls import java.net.URI import kotlin.io.path.toPath -fun String.toPathUri(): URI = URI(this).toPath().toUri() +internal fun String.toPathUri(): URI = URI(this).toPath().toUri() + +internal fun URI.startsWith(other: URI): Boolean = toPath().startsWith(other.toPath()) diff --git a/language-server/src/test/kotlin/tools/samt/ls/FileInfoTest.kt b/language-server/src/test/kotlin/tools/samt/ls/FileInfoTest.kt new file mode 100644 index 00000000..a29c6b12 --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/FileInfoTest.kt @@ -0,0 +1,57 @@ +package tools.samt.ls + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import tools.samt.common.SourceFile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FileInfoTest { + @Test + fun `parseFile conserves path`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar") + val fileInfo = parseFile(sourceFile) + assertEquals(sourceFile.path, fileInfo.path) + } + + @Test + fun `parseFile handles fatal lexer errors`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar;") + assertDoesNotThrow { + val fileInfo = parseFile(sourceFile) + assertEquals(emptyList(), fileInfo.tokens) + assertNull(fileInfo.fileNode) + assertTrue(fileInfo.diagnosticContext.hasErrors()) + } + } + + @Test + fun `parseFile handles non fatal lexer errors`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "\"foo") + val fileInfo = parseFile(sourceFile) + assertTrue(fileInfo.tokens.isNotEmpty()) + assertNull(fileInfo.fileNode) + assertTrue(fileInfo.diagnosticContext.hasErrors()) + } + + @Test + fun `parseFile handles fatal parser errors`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar record Greeter { } }") + assertDoesNotThrow { + val fileInfo = parseFile(sourceFile) + assertNull(fileInfo.fileNode) + assertTrue(fileInfo.diagnosticContext.hasErrors()) + } + } + + @Test + fun `parseFile returns tokens and AST`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar") + val fileInfo = parseFile(sourceFile) + assertEquals(emptyList(), fileInfo.diagnosticContext.messages) + assertTrue(fileInfo.tokens.isNotEmpty()) + assertNotNull(fileInfo.fileNode) + } +} diff --git a/language-server/src/test/kotlin/tools/samt/ls/MappingTest.kt b/language-server/src/test/kotlin/tools/samt/ls/MappingTest.kt new file mode 100644 index 00000000..dda8a861 --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/MappingTest.kt @@ -0,0 +1,91 @@ +package tools.samt.ls + +import org.eclipse.lsp4j.DiagnosticRelatedInformation +import org.eclipse.lsp4j.Location +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.Range +import tools.samt.common.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import tools.samt.common.Location as SamtLocation + +class MappingTest { + + @Test + fun `toDiagnostic returns null if no highlights`() { + val message = DiagnosticMessage( + DiagnosticSeverity.Error, + "message", + listOf(), + listOf() + ) + val diagnostic = message.toDiagnostic() + assertNull(diagnostic) + } + + @Test + fun `toDiagnostic converts to LSP diagnostic`() { + val location = SamtLocation( + SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar\nrecord Foo {}"), + FileOffset(0, 0, 2), + FileOffset(10, 1, 3) + ) + val highlight = DiagnosticHighlight( + "highlight message", + location, + "suggestion", + false + ) + val message = DiagnosticMessage( + DiagnosticSeverity.Error, + "message", + listOf(highlight), + listOf() + ) + val diagnostic = message.toDiagnostic() + assertNotNull(diagnostic) + assertEquals("message", diagnostic.message) + assertEquals(org.eclipse.lsp4j.DiagnosticSeverity.Error, diagnostic.severity) + assertEquals("samt", diagnostic.source) + assertEquals(Range( + Position(0, 2), + Position(1, 3) + ), diagnostic.range) + assertEquals(listOf(DiagnosticRelatedInformation( + Location("file:///tmp/test.samt".toPathUri().toString(), Range( + Position(0, 2), + Position(1, 3) + )), + "highlight message" + )), diagnostic.relatedInformation) + } + + @Test + fun `toLspSeverity converts to LSP severity`() { + val expected = mapOf( + DiagnosticSeverity.Error to org.eclipse.lsp4j.DiagnosticSeverity.Error, + DiagnosticSeverity.Warning to org.eclipse.lsp4j.DiagnosticSeverity.Warning, + DiagnosticSeverity.Info to org.eclipse.lsp4j.DiagnosticSeverity.Information + ) + assertEquals(expected, DiagnosticSeverity.values().associateWith { it.toLspSeverity() }) + } + + @Test + fun `toRange converts to LSP range`() { + val sourceFile = SourceFile("file:///tmp/test.samt".toPathUri(), "package foo.bar\nrecord Foo {}") + val start = FileOffset(0, 0, 2) + val end = FileOffset(10, 1, 3) + val location = SamtLocation( + sourceFile, + start, + end + ) + val range = location.toRange() + assertEquals(Range( + Position(0, 2), + Position(1, 3) + ), range) + } +} diff --git a/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt b/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt new file mode 100644 index 00000000..82b826e3 --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/SamtFolderTest.kt @@ -0,0 +1,58 @@ +package tools.samt.ls + +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 fileInfo = parseFile(SourceFile(uri, "package foo.bar")) + workspace.set(fileInfo) + assertSame(fileInfo, workspace[uri]) + assertTrue(uri in workspace) + assertContains(workspace 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 fileInfo = parseFile(SourceFile(uri, "package foo.bar")) + workspace.set(fileInfo) + workspace.remove(uri) + assertNull(workspace[uri]) + assertFalse(uri in workspace) + assertFalse(fileInfo in workspace) + } + + @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 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()) + } + + @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 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() + assertEquals(2, messages.size) + } +} diff --git a/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt b/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt new file mode 100644 index 00000000..aed71d14 --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/SamtWorkspaceTest.kt @@ -0,0 +1,158 @@ +package tools.samt.ls + +import tools.samt.common.DiagnosticSeverity +import tools.samt.common.SourceFile +import kotlin.test.* + +class SamtWorkspaceTest { + @Test + fun `getFile retrieves file`() { + val folder = SamtFolder("file:///tmp/test".toPathUri()) + val workspace = SamtWorkspace() + val uri = "file:///tmp/test/model.samt".toPathUri() + workspace.addFolder(folder) + val fileInfo = parseFile(SourceFile (uri, "package foo.bar")) + folder.set(fileInfo) + assertSame(fileInfo, workspace.getFile(uri)) + } + + @Test + fun `file is in folder snapshot`() { + val folder = SamtFolder("file:///tmp/test".toPathUri()) + val workspace = SamtWorkspace() + val uri = "file:///tmp/test/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()) + 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 workspace = SamtWorkspace() + workspace.addFolder(outer) + workspace.addFolder(inner) + val snapshot = workspace.getFolderSnapshot("file:///tmp/test/inner".toPathUri()) + assertEquals(outer.path, snapshot?.path) + } + + @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 workspace = SamtWorkspace() + workspace.addFolder(inner) + workspace.addFolder(outer) + val snapshot = workspace.getFolderSnapshot("file:///tmp/test/inner".toPathUri()) + assertEquals(outer.path, snapshot?.path) + } + + @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() + val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) + workspace.setFile(fileInfo) + val messages = workspace.getPendingMessages()[uri] + assertEquals(DiagnosticSeverity.Error, messages?.single()?.severity) + } + + @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() + val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) + workspace.setFile(fileInfo) + workspace.removeFile(uri) + val messages = workspace.getPendingMessages() + assertEquals(mapOf(uri to emptyList()), messages) + } + + @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.setFile(file1) + workspace.setFile(file2) + workspace.removeFolder("file:///tmp/test".toPathUri()) + val messages = workspace.getPendingMessages() + assertEquals(mapOf(file1.path to emptyList(), file2.path to emptyList()), messages) + } + + @Test + fun `getPendingMessages is empty after clearing changes`() { + val workspace = SamtWorkspace() + workspace.addFolder(SamtFolder("file:///tmp/test".toPathUri())) + val uri = "file:///tmp/test/model.samt".toPathUri() + val fileInfo = parseFile(SourceFile (uri, "package foo.bar record Foo {")) + workspace.setFile(fileInfo) + workspace.clearChanges() + val messages = workspace.getPendingMessages() + assertEquals(emptyMap(), messages) + } + + @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.setFile(file1) + workspace.setFile(file2) + workspace.buildSemanticModel() + val messagesBefore = workspace.getPendingMessages() + assertEquals(mapOf(file1.path to emptyList(), file2.path to emptyList()), messagesBefore) + workspace.removeFile(file1.path) + workspace.buildSemanticModel() + val messagesAfter = workspace.getPendingMessages() + assertEquals(emptyList(), messagesAfter[file1.path]) + assertNotNull(messagesAfter[file2.path]).single().let { + assertEquals(DiagnosticSeverity.Error, it.severity) + assertEquals("Type 'Foo' could not be resolved", it.message) + } + } + + @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.setFile(file1) + workspace.setFile(file2) + workspace.removeDirectory("file:///tmp/test/subfolder".toPathUri()) + val messages = workspace.getPendingMessages() + assertEquals(mapOf(file1.path to emptyList(), file2.path to emptyList()), messages) + } + + @Test + fun `package 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.setFile(file1) + assertNull(workspace.getRootPackage(file1.path)) + workspace.buildSemanticModel() + val rootPackage = workspace.getRootPackage(file1.path) + assertNotNull(rootPackage) + } + + @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.setFile(parseFile(sourceFile)) + assertEquals(1, workspace.getPendingMessages().size) + workspace.clearChanges() + workspace.setFile(parseFile(sourceFile)) + assertEquals(emptyMap(), workspace.getPendingMessages()) + } +} diff --git a/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt b/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt index 7f6929d7..7d5da35f 100644 --- a/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt +++ b/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt @@ -2,10 +2,27 @@ package tools.samt.ls import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class UriTest { @Test fun `correctly transforms encoded URIs`() { assertEquals("file:///c:/test/directory", "file:///c%3A/test/directory".toPathUri().toString()) } + + @Test + fun `startsWith returns true if URI is contained in folder`() { + assertTrue("file:///c:/test/directory".toPathUri().startsWith("file:///c:/test".toPathUri())) + } + + @Test + fun `startsWith returns false if URI is just a prefix`() { + assertFalse("file:///c:/testtest".toPathUri().startsWith("file:///c:/test".toPathUri())) + } + + @Test + fun `startsWith returns true if URIs are identical`() { + assertTrue("file:///c:/test".toPathUri().startsWith("file:///c:/test".toPathUri())) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 31cb794b..34aac998 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,7 +15,7 @@ dependencyResolutionManagement { val jCommander = "1.82" val mordant = "2.0.0-beta13" val kotlinxSerialization = "1.5.0" - val kover = "0.7.0-Beta" + val kover = "0.7.0" val lsp4j = "0.20.1" create("libs") {