diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 62268c83..cb0d7d49 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -19,7 +19,7 @@ jobs: uses: gradle/gradle-build-action@v2 - name: Build CLI - run: ./gradlew --no-daemon :cli:shadowDistZip :cli:shadowDistTar + run: ./gradlew --no-daemon :cli:shadowDistZip :cli:shadowDistTar :language-server:shadowJar - name: Rename cli-shadow to cli run: | @@ -32,4 +32,5 @@ jobs: files: | cli/build/distributions/cli.zip cli/build/distributions/cli.tar + language-server/build/libs/samt-ls.jar fail_on_unmatched_files: true diff --git a/.idea/runConfigurations/Language_Server_Debug.xml b/.idea/runConfigurations/Language_Server_Debug.xml new file mode 100644 index 00000000..b86218de --- /dev/null +++ b/.idea/runConfigurations/Language_Server_Debug.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/cli/src/main/kotlin/tools/samt/cli/ASTPrinter.kt b/cli/src/main/kotlin/tools/samt/cli/ASTPrinter.kt index 6abffdfa..aac32b37 100644 --- a/cli/src/main/kotlin/tools/samt/cli/ASTPrinter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/ASTPrinter.kt @@ -56,7 +56,7 @@ internal object ASTPrinter { } private fun dumpInfo(node: Node): String? = when (node) { - is FileNode -> gray(node.sourceFile.absolutePath) + is FileNode -> gray(node.sourceFile.path.path) is RequestResponseOperationNode -> if (node.isAsync) red("async") else null is IdentifierNode -> yellow(node.name) is ImportBundleIdentifierNode -> yellow(node.name) + if (node.isWildcard) yellow(".*") else "" diff --git a/cli/src/main/kotlin/tools/samt/cli/App.kt b/cli/src/main/kotlin/tools/samt/cli/App.kt index 014b5bc3..182558dc 100644 --- a/cli/src/main/kotlin/tools/samt/cli/App.kt +++ b/cli/src/main/kotlin/tools/samt/cli/App.kt @@ -3,6 +3,7 @@ package tools.samt.cli import com.beust.jcommander.JCommander import com.github.ajalt.mordant.terminal.Terminal import tools.samt.common.DiagnosticController +import kotlin.io.path.Path fun main(args: Array) { val cliArgs = CliArgs() @@ -24,7 +25,7 @@ fun main(args: Array) { val terminal = Terminal() - val workingDirectory = System.getProperty("user.dir") + val workingDirectory = Path(System.getProperty("user.dir")).toUri() val controller = DiagnosticController(workingDirectory) diff --git a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt index 74235f37..da1ec894 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt @@ -16,7 +16,7 @@ internal fun compile(command: CompileCommand, controller: DiagnosticController) // attempt to parse each source file into an AST val fileNodes = buildList { for (source in sourceFiles) { - val context = controller.createContext(source) + val context = controller.getOrCreateContext(source) val tokenStream = Lexer.scan(source.content.reader(), context) if (context.hasErrors()) { diff --git a/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt b/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt index 25ead32c..068930e6 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt @@ -19,12 +19,12 @@ internal fun dump(command: DumpCommand, terminal: Terminal, controller: Diagnost // attempt to parse each source file into an AST val fileNodes = buildList { for (source in sourceFiles) { - val context = controller.createContext(source) + val context = controller.getOrCreateContext(source) if (dumpAll || command.dumpTokens) { // create duplicate scan because sequence can only be iterated once val tokenStream = Lexer.scan(source.content.reader(), context) - terminal.println("Tokens for ${source.absolutePath}:") + terminal.println("Tokens for ${source.path}:") terminal.println(TokenPrinter.dump(tokenStream)) // clear the diagnostic messages so that messages aren't duplicated context.messages.clear() diff --git a/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt b/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt index e4dae8ab..4a959abe 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt @@ -2,34 +2,11 @@ package tools.samt.cli import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource import java.io.File -internal fun List.readSamtSourceFiles(controller: DiagnosticController): List { - val files = map { File(it) }.ifEmpty { gatherSamtFiles(controller.workingDirectoryAbsolutePath) } +internal fun List.readSamtSourceFiles(controller: DiagnosticController): List = + map { File(it) }.ifEmpty { collectSamtFiles(controller.workingDirectory) } + .readSamtSource(controller) - return buildList { - for (file in files) { - if (!file.exists()) { - controller.reportGlobalError("File '${file.canonicalPath}' does not exist") - continue - } - - if (!file.canRead()) { - controller.reportGlobalError("File '${file.canonicalPath}' cannot be read, bad file permissions?") - continue - } - - if (file.extension != "samt") { - controller.reportGlobalError("File '${file.canonicalPath}' must end in .samt") - continue - } - - add(SourceFile(file.canonicalPath, content = file.readText())) - } - } -} - -internal fun gatherSamtFiles(directory: String): List { - val dir = File(directory) - return dir.walkTopDown().filter { it.isFile && it.extension == "samt" }.toList() -} diff --git a/cli/src/main/kotlin/tools/samt/cli/CliWrapper.kt b/cli/src/main/kotlin/tools/samt/cli/CliWrapper.kt index 342179e0..a0f018d0 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliWrapper.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliWrapper.kt @@ -10,7 +10,7 @@ import java.io.File import java.net.URL internal fun wrapper(command: WrapperCommand, terminal: Terminal, controller: DiagnosticController) { - val workingDirectory = File(controller.workingDirectoryAbsolutePath) + val workingDirectory = File(controller.workingDirectory) val dotSamtDirectory = File(workingDirectory, ".samt") if (command.init) { diff --git a/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt b/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt index 8e0b2067..57e05bcd 100644 --- a/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt @@ -5,7 +5,6 @@ import com.github.ajalt.mordant.rendering.TextColors.* import com.github.ajalt.mordant.rendering.TextStyles.* import com.github.ajalt.mordant.terminal.Terminal import tools.samt.common.* -import java.io.File internal class DiagnosticFormatter( private val diagnosticController: DiagnosticController, @@ -119,11 +118,6 @@ internal class DiagnosticFormatter( DiagnosticSeverity.Info -> formatTextForSeverity("INFO:", severity, withBold = true) } - private fun formatFilePathRelativeToWorkingDirectory(filePath: String): String { - val workingDirectory = diagnosticController.workingDirectoryAbsolutePath - return filePath.removePrefix(workingDirectory).removePrefix(File.separator) - } - private fun formatGlobalMessage(message: DiagnosticGlobalMessage): String = buildString { append(formatSeverityIndicator(message.severity)) append(" ") @@ -140,14 +134,14 @@ internal class DiagnosticFormatter( appendLine() val errorSourceFilePath = if (message.highlights.isNotEmpty()) { - message.highlights.first().location.source.absolutePath + message.highlights.first().location.source.path } else { - context.source.absolutePath + context.source.path } // -----> : append(gray(" ---> ")) - append(formatFilePathRelativeToWorkingDirectory(errorSourceFilePath)) + append(diagnosticController.workingDirectory.relativize(errorSourceFilePath)) if (message.highlights.isNotEmpty()) { val firstHighlight = message.highlights.first() val firstHighlightLocation = firstHighlight.location @@ -173,8 +167,8 @@ internal class DiagnosticFormatter( require(highlights.isNotEmpty()) // group highlights by source file - val mainSourceFileAbsolutePath = highlights.first().location.source.absolutePath - val highlightsBySourceFile = highlights.groupBy { it.location.source.absolutePath } + val mainSourceFileAbsolutePath = highlights.first().location.source.path + val highlightsBySourceFile = highlights.groupBy { it.location.source.path } // print the highlights for the main source file val mainSourceFileHighlights = highlightsBySourceFile.getValue(mainSourceFileAbsolutePath) diff --git a/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt b/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt index 641c7419..11e7d86f 100644 --- a/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt +++ b/cli/src/test/kotlin/tools/samt/cli/ASTPrinterTest.kt @@ -5,6 +5,7 @@ import tools.samt.common.SourceFile import tools.samt.lexer.Lexer import tools.samt.parser.FileNode import tools.samt.parser.Parser +import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -14,6 +15,7 @@ class ASTPrinterTest { fun `correctly formats an AST dump`() { val fileNode = parse(""" import foo.bar.baz.* + import foo.bar.baz.A as B package test.stuff @@ -22,8 +24,9 @@ class ASTPrinterTest { age: Integer(0..150) } - record B {} + enum E { A, B, C } + @Description("This is a service") service MyService { testmethod(foo: A): B } @@ -39,52 +42,65 @@ class ASTPrinterTest { │ ├─IdentifierNode foo <1:8> │ ├─IdentifierNode bar <1:12> │ └─IdentifierNode baz <1:16> - ├─PackageDeclarationNode <3:1> - │ └─BundleIdentifierNode test.stuff <3:9> - │ ├─IdentifierNode test <3:9> - │ └─IdentifierNode stuff <3:14> - ├─RecordDeclarationNode <5:1> - │ ├─IdentifierNode A <5:8> - │ ├─RecordFieldNode <6:3> - │ │ ├─IdentifierNode name <6:3> - │ │ └─CallExpressionNode <6:9> - │ │ ├─BundleIdentifierNode String <6:9> - │ │ │ └─IdentifierNode String <6:9> - │ │ ├─RangeExpressionNode <6:16> - │ │ │ ├─IntegerNode 10 <6:16> - │ │ │ └─IntegerNode 20 <6:20> - │ │ └─CallExpressionNode <6:24> - │ │ ├─BundleIdentifierNode pattern <6:24> - │ │ │ └─IdentifierNode pattern <6:24> - │ │ └─StringNode "hehe" <6:32> - │ └─RecordFieldNode <7:3> - │ ├─IdentifierNode age <7:3> - │ └─CallExpressionNode <7:8> - │ ├─BundleIdentifierNode Integer <7:8> - │ │ └─IdentifierNode Integer <7:8> - │ └─RangeExpressionNode <7:16> - │ ├─IntegerNode 0 <7:16> - │ └─IntegerNode 150 <7:19> - ├─RecordDeclarationNode <10:1> - │ └─IdentifierNode B <10:8> - └─ServiceDeclarationNode <12:1> - ├─IdentifierNode MyService <12:9> - └─RequestResponseOperationNode <13:3> - ├─IdentifierNode testmethod <13:3> - ├─OperationParameterNode <13:14> - │ ├─IdentifierNode foo <13:14> - │ └─BundleIdentifierNode A <13:19> - │ └─IdentifierNode A <13:19> - └─BundleIdentifierNode B <13:23> - └─IdentifierNode B <13:23> + ├─TypeImportNode <2:1> + │ ├─ImportBundleIdentifierNode foo.bar.baz.A <2:8> + │ │ ├─IdentifierNode foo <2:8> + │ │ ├─IdentifierNode bar <2:12> + │ │ ├─IdentifierNode baz <2:16> + │ │ └─IdentifierNode A <2:20> + │ └─IdentifierNode B <2:25> + ├─PackageDeclarationNode <4:1> + │ └─BundleIdentifierNode test.stuff <4:9> + │ ├─IdentifierNode test <4:9> + │ └─IdentifierNode stuff <4:14> + ├─RecordDeclarationNode <6:1> + │ ├─IdentifierNode A <6:8> + │ ├─RecordFieldNode <7:3> + │ │ ├─IdentifierNode name <7:3> + │ │ └─CallExpressionNode <7:9> + │ │ ├─BundleIdentifierNode String <7:9> + │ │ │ └─IdentifierNode String <7:9> + │ │ ├─RangeExpressionNode <7:16> + │ │ │ ├─IntegerNode 10 <7:16> + │ │ │ └─IntegerNode 20 <7:20> + │ │ └─CallExpressionNode <7:24> + │ │ ├─BundleIdentifierNode pattern <7:24> + │ │ │ └─IdentifierNode pattern <7:24> + │ │ └─StringNode "hehe" <7:32> + │ └─RecordFieldNode <8:3> + │ ├─IdentifierNode age <8:3> + │ └─CallExpressionNode <8:8> + │ ├─BundleIdentifierNode Integer <8:8> + │ │ └─IdentifierNode Integer <8:8> + │ └─RangeExpressionNode <8:16> + │ ├─IntegerNode 0 <8:16> + │ └─IntegerNode 150 <8:19> + ├─EnumDeclarationNode <11:1> + │ ├─IdentifierNode E <11:6> + │ ├─IdentifierNode A <11:10> + │ ├─IdentifierNode B <11:13> + │ └─IdentifierNode C <11:16> + └─ServiceDeclarationNode <14:1> + ├─IdentifierNode MyService <14:9> + ├─RequestResponseOperationNode <15:3> + │ ├─IdentifierNode testmethod <15:3> + │ ├─OperationParameterNode <15:14> + │ │ ├─IdentifierNode foo <15:14> + │ │ └─BundleIdentifierNode A <15:19> + │ │ └─IdentifierNode A <15:19> + │ └─BundleIdentifierNode B <15:23> + │ └─IdentifierNode B <15:23> + └─AnnotationNode <13:1> + ├─IdentifierNode Description <13:2> + └─StringNode "This is a service" <13:14> """.trimIndent().trim(), dumpWithoutColorCodes.trimIndent().trim()) } private fun parse(source: String): FileNode { - val filePath = "/tmp/ASTPrinterTest.samt" + val filePath = URI("file:///tmp/ASTPrinterTest.samt") val sourceFile = SourceFile(filePath, source) - val diagnosticController = DiagnosticController("/tmp") - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) val fileTree = Parser.parse(sourceFile, stream, diagnosticContext) assertFalse(diagnosticContext.hasErrors(), "Expected no errors, but had errors") diff --git a/cli/src/test/kotlin/tools/samt/cli/DiagnosticFormatterTest.kt b/cli/src/test/kotlin/tools/samt/cli/DiagnosticFormatterTest.kt index e8b2596f..d3363f45 100644 --- a/cli/src/test/kotlin/tools/samt/cli/DiagnosticFormatterTest.kt +++ b/cli/src/test/kotlin/tools/samt/cli/DiagnosticFormatterTest.kt @@ -7,8 +7,8 @@ import tools.samt.lexer.Lexer import tools.samt.parser.EnumDeclarationNode import tools.samt.parser.FileNode import tools.samt.parser.Parser +import java.net.URI import kotlin.io.path.Path -import kotlin.io.path.absolutePathString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -16,7 +16,7 @@ import kotlin.test.assertFalse class DiagnosticFormatterTest { @Test fun `global messages`() { - val controller = DiagnosticController("/tmp") + val controller = DiagnosticController(URI("file:///tmp")) controller.reportGlobalError("This is a global error") controller.reportGlobalWarning("This is a global warning") controller.reportGlobalInfo("This is a global info") @@ -40,12 +40,12 @@ class DiagnosticFormatterTest { @Test fun `file messages with no highlights`() { - val baseDirectory = Path("/tmp").absolutePathString() - val filePath = Path("/tmp", "test.txt").absolutePathString() + val baseDirectory = Path("/tmp").toUri() + val filePath = Path("/tmp", "test.txt").toUri() val controller = DiagnosticController(baseDirectory) val source = "" val sourceFile = SourceFile(filePath, source) - val context = controller.createContext(sourceFile) + val context = controller.getOrCreateContext(sourceFile) context.error { message("some error") @@ -480,11 +480,11 @@ class DiagnosticFormatterTest { } private fun parse(source: String): Triple { - val baseDirectory = Path("/tmp").absolutePathString() - val filePath = Path("/tmp", "DiagnosticFormatterTest.samt").absolutePathString() + val baseDirectory = Path("/tmp").toUri() + val filePath = Path("/tmp", "DiagnosticFormatterTest.samt").toUri() val sourceFile = SourceFile(filePath, source) val diagnosticController = DiagnosticController(baseDirectory) - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) val fileTree = Parser.parse(sourceFile, stream, diagnosticContext) assertFalse(diagnosticContext.hasErrors(), "Expected no errors, but had errors") diff --git a/cli/src/test/kotlin/tools/samt/cli/TokenPrinterTest.kt b/cli/src/test/kotlin/tools/samt/cli/TokenPrinterTest.kt index 9496f0c8..76193085 100644 --- a/cli/src/test/kotlin/tools/samt/cli/TokenPrinterTest.kt +++ b/cli/src/test/kotlin/tools/samt/cli/TokenPrinterTest.kt @@ -4,6 +4,7 @@ import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile import tools.samt.lexer.Lexer import tools.samt.lexer.Token +import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -46,10 +47,10 @@ class TokenPrinterTest { } private fun parse(source: String): Sequence { - val filePath = "/tmp/TokenPrinterTest.samt" + val filePath = URI("file:///tmp/TokenPrinterTest.samt") val sourceFile = SourceFile(filePath, source) - val diagnosticController = DiagnosticController("/tmp") - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) assertFalse(diagnosticContext.hasErrors(), "Expected no errors, but had errors") return stream diff --git a/common/src/main/kotlin/tools/samt/common/Diagnostics.kt b/common/src/main/kotlin/tools/samt/common/Diagnostics.kt index 18f5f465..d212d295 100644 --- a/common/src/main/kotlin/tools/samt/common/Diagnostics.kt +++ b/common/src/main/kotlin/tools/samt/common/Diagnostics.kt @@ -1,5 +1,7 @@ package tools.samt.common +import java.net.URI + enum class DiagnosticSeverity { Error, Warning, Info } @@ -29,19 +31,19 @@ data class DiagnosticHighlight( ) data class SourceFile( - // absolute path to the source file - val absolutePath: String, + /** Absolute path to the source file */ + val path: URI, - // the content of the source file as a string + /** Content of the source file as a string */ val content: String, ) { - // each line of the source file + /** Each line of the source file */ val sourceLines: List = content.lines() } class DiagnosticException(message: DiagnosticMessage) : RuntimeException(message.message) -class DiagnosticController(val workingDirectoryAbsolutePath: String) { +class DiagnosticController(val workingDirectory: URI) { /** * All diagnostic contexts, one for each source file. @@ -56,7 +58,7 @@ class DiagnosticController(val workingDirectoryAbsolutePath: String) { /** * Creates a new diagnostic context for the given source file or returns already existing one. * */ - fun createContext(source: SourceFile): DiagnosticContext { + fun getOrCreateContext(source: SourceFile): DiagnosticContext { val foundContext = contexts.find { it.source == source} if (foundContext != null) return foundContext return DiagnosticContext(source).also { contexts.add(it) } @@ -69,10 +71,10 @@ class DiagnosticController(val workingDirectoryAbsolutePath: String) { globalMessages.add(DiagnosticGlobalMessage(severity, message)) } - fun hasMessages(): Boolean = contexts.any { it.hasMessages() } or globalMessages.isNotEmpty() - fun hasErrors(): Boolean = contexts.any { it.hasErrors() } or globalMessages.any { it.severity == DiagnosticSeverity.Error } - fun hasWarnings(): Boolean = contexts.any { it.hasWarnings() } or globalMessages.any { it.severity == DiagnosticSeverity.Warning } - fun hasInfos(): Boolean = contexts.any { it.hasInfos() } or globalMessages.any { it.severity == DiagnosticSeverity.Info } + fun hasMessages(): Boolean = contexts.any { it.hasMessages() } || globalMessages.isNotEmpty() + fun hasErrors(): Boolean = contexts.any { it.hasErrors() } || globalMessages.any { it.severity == DiagnosticSeverity.Error } + fun hasWarnings(): Boolean = contexts.any { it.hasWarnings() } || globalMessages.any { it.severity == DiagnosticSeverity.Warning } + fun hasInfos(): Boolean = contexts.any { it.hasInfos() } || globalMessages.any { it.severity == DiagnosticSeverity.Info } } class DiagnosticContext( @@ -153,7 +155,6 @@ class DiagnosticMessageBuilder( fun build(): DiagnosticMessage { requireNotNull(message) - highlights.sortWith(compareBy({ it.location.start.row }, { it.location.start.col })) return DiagnosticMessage(severity, message!!, highlights, annotations) } } diff --git a/common/src/main/kotlin/tools/samt/common/Files.kt b/common/src/main/kotlin/tools/samt/common/Files.kt new file mode 100644 index 00000000..4a638100 --- /dev/null +++ b/common/src/main/kotlin/tools/samt/common/Files.kt @@ -0,0 +1,38 @@ +package tools.samt.common + +import java.io.File +import java.net.URI + +fun List.readSamtSource(controller: DiagnosticController): List { + return buildList { + for (file in this@readSamtSource) { + + if (!file.exists()) { + controller.reportGlobalError("File '${file.path}' does not exist") + continue + } + + if (!file.canRead()) { + controller.reportGlobalError("File '${file.path}' cannot be read, bad file permissions?") + continue + } + + if (file.extension != "samt") { + controller.reportGlobalError("File '${file.path}' must end in .samt") + continue + } + + if (!file.isFile) { + controller.reportGlobalError("'${file.path}' is not a file") + continue + } + + add(SourceFile(file.toPath().toUri(), content = file.readText())) + } + } +} + +fun collectSamtFiles(directory: URI): List { + val dir = File(directory) + return dir.walkTopDown().filter { it.isFile && it.extension == "samt" }.toList() +} diff --git a/common/src/test/kotlin/tools/samt/common/DiagnosticsTest.kt b/common/src/test/kotlin/tools/samt/common/DiagnosticsTest.kt index b879964f..b1e61642 100644 --- a/common/src/test/kotlin/tools/samt/common/DiagnosticsTest.kt +++ b/common/src/test/kotlin/tools/samt/common/DiagnosticsTest.kt @@ -1,13 +1,14 @@ package tools.samt.common import org.junit.jupiter.api.assertThrows +import java.net.URI import kotlin.test.* class DiagnosticsTest { @Test fun `global messages`() { - val controller = DiagnosticController("/tmp") + val controller = DiagnosticController(URI("file:///tmp")) controller.reportGlobalError("some error") controller.reportGlobalWarning("some warning") controller.reportGlobalInfo("some information") @@ -29,14 +30,14 @@ class DiagnosticsTest { @Test fun `fatal error messages`() { - val controller = DiagnosticController("/tmp") - val sourcePath = "/tmp/sourceFile" + val controller = DiagnosticController(URI("file:///tmp")) + val sourcePath = URI("file:///tmp/sourceFile") val sourceCode = """ import foo as bar package debug """.trimIndent() val sourceFile = SourceFile(sourcePath, sourceCode) - val context = controller.createContext(sourceFile) + val context = controller.getOrCreateContext(sourceFile) assertThrows("some fatal error") { context.fatal { @@ -58,14 +59,14 @@ class DiagnosticsTest { @Test fun `message highlights and annotations`() { - val controller = DiagnosticController("/tmp") - val sourcePath = "/tmp/sourceFile" + val controller = DiagnosticController(URI("file:///tmp")) + val sourcePath = URI("file:///tmp/sourceFile") val sourceCode = """ import foo as bar package debug """.trimIndent().trim() val sourceFile = SourceFile(sourcePath, sourceCode) - val context = controller.createContext(sourceFile) + val context = controller.getOrCreateContext(sourceFile) val importStatementStartOffset = FileOffset(0, 0, 0) val importStatementEndOffset = FileOffset(17, 0, 17) diff --git a/common/src/test/kotlin/tools/samt/common/FilesTest.kt b/common/src/test/kotlin/tools/samt/common/FilesTest.kt new file mode 100644 index 00000000..80900dfb --- /dev/null +++ b/common/src/test/kotlin/tools/samt/common/FilesTest.kt @@ -0,0 +1,47 @@ +package tools.samt.common + +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 FilesTest { + private val testDirectory = Path("src/test/resources/test-files") + @BeforeTest + fun setup() { + assertTrue(testDirectory.exists() && testDirectory.isDirectory(), "Test directory does not exist") + } + + @Test + fun `collectSamtFiles only returns files with samt extension`() { + val samtFiles = collectSamtFiles(testDirectory.toUri()) + + val relativeFilePaths = samtFiles.map { testDirectory.toUri().relativize(it.toPath().toUri()).toString() }.sorted() + assertEquals(listOf("foo.samt", "src/bar.samt", "src/baz.samt"), relativeFilePaths) + } + @Test + fun `readSamtSource only returns files with samt extension`() { + val files = listOf( + testDirectory.resolve("foo.samt").toFile(), + testDirectory.resolve("dummy.samt.txt").toFile(), + testDirectory.resolve("src/bar.samt").toFile(), + testDirectory.resolve("src/baz.samt").toFile(), + testDirectory.resolve("src/missing.samt").toFile(), + testDirectory.resolve(".samt").toFile(), + ) + val controller = DiagnosticController(testDirectory.toUri()) + val samtFiles = files.readSamtSource(controller) + + val relativeFilePaths = samtFiles.map { testDirectory.toUri().relativize(it.path).toString() }.sorted() + assertEquals(listOf("foo.samt", "src/bar.samt", "src/baz.samt"), relativeFilePaths) + + assertEquals(listOf( + "File '${testDirectory.resolve("dummy.samt.txt").toFile().path}' must end in .samt", + "File '${testDirectory.resolve("src/missing.samt").toFile().path}' does not exist", + "'${testDirectory.resolve(".samt").toFile().path}' is not a file", + ), controller.globalMessages.filter { it.severity == DiagnosticSeverity.Error }.map { it.message }) + } +} diff --git a/common/src/test/kotlin/tools/samt/common/LocationTest.kt b/common/src/test/kotlin/tools/samt/common/LocationTest.kt index ccaddb13..abec4de5 100644 --- a/common/src/test/kotlin/tools/samt/common/LocationTest.kt +++ b/common/src/test/kotlin/tools/samt/common/LocationTest.kt @@ -2,11 +2,12 @@ package tools.samt.common import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows +import java.net.URI import kotlin.test.* class LocationTest { private val dummySource = SourceFile( - "locations.samt", """ + URI("file://locations.samt"), """ import foo import bar diff --git a/common/src/test/resources/test-files/.samt/.gitkeep b/common/src/test/resources/test-files/.samt/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/common/src/test/resources/test-files/dummy.samt.txt b/common/src/test/resources/test-files/dummy.samt.txt new file mode 100644 index 00000000..e69de29b diff --git a/common/src/test/resources/test-files/foo.samt b/common/src/test/resources/test-files/foo.samt new file mode 100644 index 00000000..e69de29b diff --git a/common/src/test/resources/test-files/src/bar.samt b/common/src/test/resources/test-files/src/bar.samt new file mode 100644 index 00000000..e69de29b diff --git a/common/src/test/resources/test-files/src/baz.samt b/common/src/test/resources/test-files/src/baz.samt new file mode 100644 index 00000000..e69de29b diff --git a/language-server/build.gradle.kts b/language-server/build.gradle.kts new file mode 100644 index 00000000..c2ec680f --- /dev/null +++ b/language-server/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + application + id("samt-core.kotlin-conventions") + alias(libs.plugins.shadow) +} + +dependencies { + implementation(libs.jCommander) + implementation(libs.lsp4j) + implementation(project(":common")) + implementation(project(":lexer")) + implementation(project(":parser")) + implementation(project(":semantic")) +} + +application { + // Define the main class for the application. + mainClass.set("tools.samt.ls.AppKt") +} + +tasks { + shadowJar { + archiveBaseName.set("samt-ls") + archiveClassifier.set("") + manifest { + attributes("Main-Class" to "tools.samt.ls.AppKt") + } + } +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/App.kt b/language-server/src/main/kotlin/tools/samt/ls/App.kt new file mode 100644 index 00000000..72d11a85 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/App.kt @@ -0,0 +1,47 @@ +package tools.samt.ls + +import com.beust.jcommander.JCommander +import org.eclipse.lsp4j.launch.LSPLauncher +import java.io.InputStream +import java.io.OutputStream +import java.io.PrintWriter +import java.net.Socket + + +private fun startServer(inStream: InputStream, outStream: OutputStream, trace: PrintWriter? = null) { + SamtLanguageServer().use { server -> + val launcher = LSPLauncher.createServerLauncher(server, inStream, outStream, false, trace) + val client = launcher.remoteProxy + redirectLogs(client) + server.connect(client) + launcher.startListening().get() + } +} + +fun main(args: Array) { + val cliArgs = CliArgs() + val jCommander = JCommander.newBuilder() + .addObject(cliArgs) + .programName("java -jar samt-ls.jar") + .build() + jCommander.parse(*args) + if (cliArgs.help) { + jCommander.usage() + return + } + + cliArgs.clientPort?.also { port -> + Socket(cliArgs.clientHost, port).use { + println("Connecting to client at ${it.remoteSocketAddress}") + startServer(it.inputStream, it.outputStream, PrintWriter(System.out)) + } + return + } + + if (cliArgs.isStdio) { + startServer(System.`in`, System.out) + return + } + + jCommander.usage() +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/CliArgs.kt b/language-server/src/main/kotlin/tools/samt/ls/CliArgs.kt new file mode 100644 index 00000000..9abe4cea --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/CliArgs.kt @@ -0,0 +1,22 @@ +package tools.samt.ls + +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters + +@Parameters(separators = "=") +class CliArgs { + @Parameter(names = ["-h", "--help"], description = "Display help", help = true) + var help: Boolean = false + + @Parameter(names = ["--client-host"]) + var clientHost = "localhost" + + /** + * Option is called Socket because that's what VS Code passes + */ + @Parameter(names = ["--socket", "--client-port"]) + var clientPort: Int? = null + + @Parameter(names = ["--stdio"]) + var isStdio: Boolean = false +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt b/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt new file mode 100644 index 00000000..293866d8 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/FileInfo.kt @@ -0,0 +1,35 @@ +package tools.samt.ls + +import tools.samt.common.DiagnosticContext +import tools.samt.common.DiagnosticException +import tools.samt.common.SourceFile +import tools.samt.lexer.Lexer +import tools.samt.lexer.Token +import tools.samt.parser.FileNode +import tools.samt.parser.Parser + +class FileInfo( + val diagnosticContext: DiagnosticContext, + val sourceFile: SourceFile, + @Suppress("unused") val tokens: List, + val fileNode: FileNode? = null, +) + +fun parseFile(sourceFile: SourceFile): FileInfo { + val diagnosticContext = DiagnosticContext(sourceFile) + + val tokens = Lexer.scan(sourceFile.content.reader(), diagnosticContext).toList() + + if (diagnosticContext.hasErrors()) { + return FileInfo(diagnosticContext, sourceFile, tokens) + } + + val fileNode = try { + Parser.parse(sourceFile, tokens.asSequence(), diagnosticContext) + } catch (e: DiagnosticException) { + // error message is added to the diagnostic console, so it can be ignored here + return FileInfo(diagnosticContext, sourceFile, tokens) + } + + return FileInfo(diagnosticContext, sourceFile, tokens, fileNode) +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/Logging.kt b/language-server/src/main/kotlin/tools/samt/ls/Logging.kt new file mode 100644 index 00000000..3c87b92f --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/Logging.kt @@ -0,0 +1,40 @@ +package tools.samt.ls + +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.services.LanguageClient +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger + +fun redirectLogs(client: LanguageClient) { + fun Level.toMessageType(): MessageType = when (this) { + Level.SEVERE -> MessageType.Error + Level.WARNING -> MessageType.Warning + Level.INFO -> MessageType.Info + Level.CONFIG -> MessageType.Log + Level.FINE -> MessageType.Log + Level.FINER -> MessageType.Log + Level.FINEST -> MessageType.Log + else -> MessageType.Log + } + fun LogRecord.toMessageParams(): MessageParams = MessageParams( + level.toMessageType(), + message + ) + + + val rootLogger = Logger.getLogger("") + rootLogger.addHandler(object : Handler() { + override fun publish(record: LogRecord) { + client.logMessage(record.toMessageParams()) + } + + override fun flush() { + } + + override fun close() { + } + }) +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt b/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt new file mode 100644 index 00000000..710a3507 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/Mapping.kt @@ -0,0 +1,33 @@ +package tools.samt.ls + +import org.eclipse.lsp4j.* +import tools.samt.common.DiagnosticMessage +import tools.samt.common.DiagnosticSeverity +import tools.samt.common.Location as SamtLocation + +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.source = "samt" + diagnostic.message = message + diagnostic.relatedInformation = highlights.filter { it.message != null }.map { + DiagnosticRelatedInformation( + Location(it.location.source.path.toString(), it.location.toRange()), + it.message + ) + } + return diagnostic +} + +fun SamtLocation.toRange(): Range { + return Range( + Position(start.row, start.col), + Position(start.row, end.col) + ) +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt b/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt new file mode 100644 index 00000000..ce0d194a --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtLanguageServer.kt @@ -0,0 +1,79 @@ +package tools.samt.ls + +import org.eclipse.lsp4j.* +import org.eclipse.lsp4j.services.* +import tools.samt.common.* +import java.io.Closeable +import java.net.URI +import java.util.concurrent.CompletableFuture +import java.util.logging.Logger +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) + + override fun initialize(params: InitializeParams): CompletableFuture = + CompletableFuture.supplyAsync { + buildSamtModel(params) + val capabilities = ServerCapabilities().apply { + setTextDocumentSync(TextDocumentSyncKind.Full) + } + InitializeResult(capabilities) + } + + override fun initialized(params: InitializedParams) { + pushDiagnostics() + } + + override fun shutdown(): CompletableFuture = CompletableFuture.completedFuture(null) + + override fun exit() { + exitProcess(0) + } + + override fun getTextDocumentService(): TextDocumentService = textDocumentService + + override fun getWorkspaceService(): WorkspaceService? = null + + override fun connect(client: LanguageClient) { + this.client = client + textDocumentService.connect(client) + logger.info("Connected to client") + } + + override fun close() { + shutdown().get() + } + + private fun buildSamtModel(params: InitializeParams) { + val folders = params.workspaceFolders.map { it.uri.toPathUri() } + 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) + } + } + + 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() } + ) + } + }.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 new file mode 100644 index 00000000..ed11a423 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtTextDocumentService.kt @@ -0,0 +1,55 @@ +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.TextDocumentService +import tools.samt.common.SourceFile +import java.net.URI +import java.util.logging.Logger + +class SamtTextDocumentService(private val workspaces: Map) : 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}") + } + + override fun didChange(params: DidChangeTextDocumentParams) { + logger.info("Changed document ${params.textDocument.uri}") + + val path = params.textDocument.uri.toPathUri() + val newText = params.contentChanges.single().text + val fileInfo = parseFile(SourceFile(path, newText)) + val workspace = getWorkspace(path) + + workspace.add(fileInfo) + workspace.buildSemanticModel() + workspace.getAllMessages().forEach { (path, messages) -> + client.publishDiagnostics( + PublishDiagnosticsParams( + path.toString(), + messages.map { it.toDiagnostic() }, + params.textDocument.version + ) + ) + } + } + + override fun didClose(params: DidCloseTextDocumentParams) { + logger.info("Closed document ${params.textDocument.uri}") + } + + override fun didSave(params: DidSaveTextDocumentParams) { + logger.info("Saved document ${params.textDocument.uri}") + } + + override fun connect(client: LanguageClient) { + this.client = client + } + + private fun getWorkspace(filePath: URI): SamtWorkspace = + workspaces.values.first { 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 new file mode 100644 index 00000000..8c03d857 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/SamtWorkspace.kt @@ -0,0 +1,39 @@ +package tools.samt.ls + +import tools.samt.common.DiagnosticController +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() + private var samtPackage: Package? = null + private var semanticController: DiagnosticController = + DiagnosticController(parserController.workingDirectory) + + fun add(fileInfo: FileInfo) { + files[fileInfo.sourceFile.path] = fileInfo + } + + operator fun get(path: URI): FileInfo? = files[path] + + override fun iterator(): Iterator = files.values.iterator() + + operator fun contains(path: URI) = path in files + + fun buildSemanticModel() { + semanticController = DiagnosticController(parserController.workingDirectory) + samtPackage = 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) + } +} diff --git a/language-server/src/main/kotlin/tools/samt/ls/Uri.kt b/language-server/src/main/kotlin/tools/samt/ls/Uri.kt new file mode 100644 index 00000000..e00398c3 --- /dev/null +++ b/language-server/src/main/kotlin/tools/samt/ls/Uri.kt @@ -0,0 +1,6 @@ +package tools.samt.ls + +import java.net.URI +import kotlin.io.path.toPath + +fun String.toPathUri(): URI = URI(this).toPath().toUri() diff --git a/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt b/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt new file mode 100644 index 00000000..7f6929d7 --- /dev/null +++ b/language-server/src/test/kotlin/tools/samt/ls/UriTest.kt @@ -0,0 +1,11 @@ +package tools.samt.ls + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UriTest { + @Test + fun `correctly transforms encoded URIs`() { + assertEquals("file:///c:/test/directory", "file:///c%3A/test/directory".toPathUri().toString()) + } +} diff --git a/lexer/src/main/kotlin/tools/samt/lexer/Lexer.kt b/lexer/src/main/kotlin/tools/samt/lexer/Lexer.kt index 0f5431df..8e935698 100644 --- a/lexer/src/main/kotlin/tools/samt/lexer/Lexer.kt +++ b/lexer/src/main/kotlin/tools/samt/lexer/Lexer.kt @@ -39,9 +39,7 @@ class Lexer private constructor( skipBlanks() resetStartPosition() } - while (true) { - yield(EndOfFileToken(windowLocation())) - } + yield(EndOfFileToken(windowLocation())) } private fun readToken(): Token? = when { @@ -81,7 +79,7 @@ class Lexer private constructor( } } - private inline fun readStructureToken(factory: (location: Location) -> T): StructureToken { + private inline fun readStructureToken(factory: (location: Location) -> T): StructureToken { readNext() return factory(windowLocation()) } @@ -283,6 +281,7 @@ class Lexer private constructor( skipLineComment() return null } + '*' -> { skipCommentBlock() return null @@ -319,7 +318,13 @@ class Lexer private constructor( if (current == '*') { readNext() nestedCommentDepth++ - commentOpenerPositionStack.add(Location(diagnostic.source, currentCharacterPosition, currentPosition)) + commentOpenerPositionStack.add( + Location( + diagnostic.source, + currentCharacterPosition, + currentPosition + ) + ) } } @@ -404,24 +409,24 @@ class Lexer private constructor( companion object { val KEYWORDS: Map StaticToken> = mapOf( - "record" to { RecordToken(it) }, - "enum" to { EnumToken(it) }, - "service" to { ServiceToken(it) }, - "alias" to { AliasToken(it) }, - "package" to { PackageToken(it) }, - "import" to { ImportToken(it) }, - "provide" to { ProvideToken(it) }, - "consume" to { ConsumeToken(it) }, - "transport" to { TransportToken(it) }, - "implements" to { ImplementsToken(it) }, - "uses" to { UsesToken(it) }, - "extends" to { ExtendsToken(it) }, - "as" to { AsToken(it) }, - "async" to { AsyncToken(it) }, - "oneway" to { OnewayToken(it) }, - "raises" to { RaisesToken(it) }, - "true" to { TrueToken(it) }, - "false" to { FalseToken(it) }, + "record" to { RecordToken(it) }, + "enum" to { EnumToken(it) }, + "service" to { ServiceToken(it) }, + "alias" to { AliasToken(it) }, + "package" to { PackageToken(it) }, + "import" to { ImportToken(it) }, + "provide" to { ProvideToken(it) }, + "consume" to { ConsumeToken(it) }, + "transport" to { TransportToken(it) }, + "implements" to { ImplementsToken(it) }, + "uses" to { UsesToken(it) }, + "extends" to { ExtendsToken(it) }, + "as" to { AsToken(it) }, + "async" to { AsyncToken(it) }, + "oneway" to { OnewayToken(it) }, + "raises" to { RaisesToken(it) }, + "true" to { TrueToken(it) }, + "false" to { FalseToken(it) }, ) fun scan(reader: Reader, diagnostics: DiagnosticContext): Sequence { diff --git a/lexer/src/test/kotlin/tools/samt/lexer/LexerTest.kt b/lexer/src/test/kotlin/tools/samt/lexer/LexerTest.kt index 5684115b..6bca5998 100644 --- a/lexer/src/test/kotlin/tools/samt/lexer/LexerTest.kt +++ b/lexer/src/test/kotlin/tools/samt/lexer/LexerTest.kt @@ -2,10 +2,11 @@ package tools.samt.lexer import org.junit.jupiter.api.assertThrows import tools.samt.common.* +import java.net.URI import kotlin.test.* class LexerTest { - private var diagnosticController = DiagnosticController("/tmp") + private var diagnosticController = DiagnosticController(URI("file:///tmp")) @Test fun `comment only file`() { @@ -368,8 +369,8 @@ SAMT!""", stream.next() } private fun readTokenStream(source: String): Pair, DiagnosticContext> { - val sourceFile = SourceFile("/tmp/test", source) - val context = diagnosticController.createContext(sourceFile) + val sourceFile = SourceFile(URI("file:///tmp/test"), source) + val context = diagnosticController.getOrCreateContext(sourceFile) return Pair(Lexer.scan(source.reader(), context).iterator(), context) } diff --git a/parser/src/main/kotlin/tools/samt/parser/Nodes.kt b/parser/src/main/kotlin/tools/samt/parser/Nodes.kt index fb7447b4..ba8d9c76 100644 --- a/parser/src/main/kotlin/tools/samt/parser/Nodes.kt +++ b/parser/src/main/kotlin/tools/samt/parser/Nodes.kt @@ -24,18 +24,19 @@ sealed class NamedDeclarationNode( sealed class ImportNode( location: Location, + val name: BundleIdentifierNode, ) : StatementNode(location) class TypeImportNode( location: Location, - val name: BundleIdentifierNode, + name: BundleIdentifierNode, val alias: IdentifierNode?, -) : ImportNode(location) +) : ImportNode(location, name) class WildcardImportNode( location: Location, - val name: BundleIdentifierNode, -) : ImportNode(location) + name: BundleIdentifierNode, +) : ImportNode(location, name) class PackageDeclarationNode( location: Location, diff --git a/parser/src/test/kotlin/tools/samt/parser/ParserTest.kt b/parser/src/test/kotlin/tools/samt/parser/ParserTest.kt index a5602abc..f19d2d5f 100644 --- a/parser/src/test/kotlin/tools/samt/parser/ParserTest.kt +++ b/parser/src/test/kotlin/tools/samt/parser/ParserTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.assertThrows import tools.samt.common.* import tools.samt.lexer.Lexer +import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -819,10 +820,10 @@ class ParserTest { } private fun parse(source: String): FileNode { - val filePath = "/tmp/ParserTest.samt" + val filePath = URI("file:///tmp/ParserTest.samt") val sourceFile = SourceFile(filePath, source) - val diagnosticController = DiagnosticController("/tmp") - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) val fileTree = Parser.parse(sourceFile, stream, diagnosticContext) assertFalse(diagnosticContext.hasErrors(), "Expected no errors, but had errors") @@ -830,10 +831,10 @@ class ParserTest { } private fun parseWithRecoverableError(source: String): Pair { - val filePath = "/tmp/ParserTest.samt" + val filePath = URI("file:///tmp/ParserTest.samt") val sourceFile = SourceFile(filePath, source) - val diagnosticController = DiagnosticController("/tmp") - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) val fileTree = Parser.parse(sourceFile, stream, diagnosticContext) assertTrue(diagnosticContext.hasErrors(), "Expected errors, but had no errors") @@ -841,10 +842,10 @@ class ParserTest { } private fun parseWithFatalError(source: String): DiagnosticException { - val filePath = "/tmp/ParserTest.samt" + val filePath = URI("file:///tmp/ParserTest.samt") val sourceFile = SourceFile(filePath, source) - val diagnosticController = DiagnosticController("/tmp") - val diagnosticContext = diagnosticController.createContext(sourceFile) + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val diagnosticContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), diagnosticContext) val exception = assertThrows { Parser.parse(sourceFile, stream, diagnosticContext) } assertTrue(diagnosticContext.hasErrors(), "Expected errors, but had no errors") diff --git a/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt b/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt index 66300a3e..87e3c366 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt @@ -4,12 +4,15 @@ import tools.samt.common.DiagnosticController import tools.samt.parser.* internal class ConstraintBuilder(private val controller: DiagnosticController) { - private fun createRange(expression: RangeExpressionNode): ResolvedTypeReference.Constraint.Range? { + private fun createRange( + expression: ExpressionNode, + argument: RangeExpressionNode, + ): ResolvedTypeReference.Constraint.Range? { fun resolveSide(expressionNode: ExpressionNode): Number? = when (expressionNode) { is NumberNode -> expressionNode.value is WildcardNode -> null else -> { - controller.createContext(expressionNode.location.source).error { + controller.getOrCreateContext(expressionNode.location.source).error { message("Range constraint argument must be a valid number range") highlight("neither a number nor '*'", expressionNode.location) help("A valid constraint would be range(1..10.5) or range(1..*)") @@ -18,13 +21,13 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } } - val lower = resolveSide(expression.left) - val higher = resolveSide(expression.right) + val lower = resolveSide(argument.left) + val higher = resolveSide(argument.right) if (lower == null && higher == null) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(argument.location.source).error { message("Range constraint must have at least one valid number") - highlight("invalid constraint", expression.location) + highlight("invalid constraint", argument.location) help("A valid constraint would be range(1..10.5) or range(1..*)") } return null @@ -33,28 +36,31 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { if (lower is Double && higher is Double && lower > higher || lower is Long && higher is Long && lower > higher ) { - controller.createContext(expression.location.source) + controller.getOrCreateContext(argument.location.source) .error { message("Range constraint must have a lower bound lower than the upper bound") - highlight("invalid constraint", expression.location) + highlight("invalid constraint", argument.location) } return null } return ResolvedTypeReference.Constraint.Range( - definition = expression, + node = expression, lowerBound = lower, upperBound = higher, ) } - private fun createSize(expression: RangeExpressionNode): ResolvedTypeReference.Constraint.Size? { + private fun createSize( + expression: ExpressionNode, + argument: RangeExpressionNode, + ): ResolvedTypeReference.Constraint.Size? { fun resolveSide(expressionNode: ExpressionNode): Long? = when (expressionNode) { is IntegerNode -> expressionNode.value is WildcardNode -> null else -> { - controller.createContext(expressionNode.location.source).error { + controller.getOrCreateContext(expressionNode.location.source).error { message("Expected size constraint argument to be a whole number or wildcard") highlight("expected whole number or wildcard '*'", expressionNode.location) help("A valid constraint would be size(1..10), size(1..*) or size(*..10)") @@ -63,47 +69,53 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } } - val lower = resolveSide(expression.left) - val higher = resolveSide(expression.right) + val lower = resolveSide(argument.left) + val higher = resolveSide(argument.right) if (lower == null && higher == null) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(argument.location.source).error { message("Constraint parameters cannot both be wildcards") - highlight("invalid constraint", expression.location) + highlight("invalid constraint", argument.location) help("A valid constraint would be range(1..10.5) or range(1..*)") } return null } if (lower != null && higher != null && lower > higher) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(argument.location.source).error { message("Size constraint lower bound must be lower than or equal to the upper bound") - highlight("invalid constraint", expression.location) + highlight("invalid constraint", argument.location) } return null } return ResolvedTypeReference.Constraint.Size( - definition = expression, + node = expression, lowerBound = lower, upperBound = higher, ) } - private fun createPattern(expression: StringNode): ResolvedTypeReference.Constraint.Pattern { + private fun createPattern( + expression: ExpressionNode, + argument: StringNode, + ): ResolvedTypeReference.Constraint.Pattern { // We will validate the pattern here in the future - return ResolvedTypeReference.Constraint.Pattern(expression, expression.value) + return ResolvedTypeReference.Constraint.Pattern(expression, argument.value) } - private fun createValue(expression: ExpressionNode): ResolvedTypeReference.Constraint.Value? { - return when (expression) { - is StringNode -> ResolvedTypeReference.Constraint.Value(expression, expression.value) - is NumberNode -> ResolvedTypeReference.Constraint.Value(expression, expression.value) - is BooleanNode -> ResolvedTypeReference.Constraint.Value(expression, expression.value) + private fun createValue( + expression: ExpressionNode, + argument: ExpressionNode, + ): ResolvedTypeReference.Constraint.Value? { + return when (argument) { + is StringNode -> ResolvedTypeReference.Constraint.Value(expression, argument.value) + is NumberNode -> ResolvedTypeReference.Constraint.Value(expression, argument.value) + is BooleanNode -> ResolvedTypeReference.Constraint.Value(expression, argument.value) else -> { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(argument.location.source).error { message("Value constraint must be a string, integer, float or boolean") - highlight("invalid constraint", expression.location) + highlight("invalid constraint", argument.location) help("A valid constraint would be value(\"foo\"), value(42) or value(false)") } null @@ -124,53 +136,56 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { when (name) { "range" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is RangeExpressionNode) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Range constraint must have exactly one range argument") highlight("invalid constraint", expression.location) help("A valid constraint would be range(1..10.5)") } return null } - return createRange(expression.arguments.first() as RangeExpressionNode) + return createRange(expression, expression.arguments.first() as RangeExpressionNode) } "size" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is RangeExpressionNode) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Size constraint must have exactly one size argument") highlight("invalid constraint", expression.location) help("A valid constraint would be size(1..10)") } return null } - return createSize(expression.arguments.first() as RangeExpressionNode) + return createSize( + expression = expression, + argument = expression.arguments.first() as RangeExpressionNode, + ) } "pattern" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is StringNode) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Pattern constraint must have exactly one string argument") highlight("invalid constraint", expression.location) help("A valid constraint would be pattern(\"a-z\")") } return null } - return createPattern(expression.arguments.first() as StringNode) + return createPattern(expression, expression.arguments.first() as StringNode) } "value" -> { if (expression.arguments.size != 1) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("value constraint must have exactly one argument") highlight("invalid constraint", expression.location) } return null } - return createValue(expression.arguments.first()) + return createValue(expression, expression.arguments.first()) } is String -> { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Constraint with name '${name}' does not exist") highlight("unknown constraint", expression.base.location) help("A valid constraint would be range(1..10.5), size(1..10), pattern(\"a-z\") or value(\"foo\")") @@ -180,30 +195,32 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } } // It might make sense to limit shorthand constraints to only the first argument - is RangeExpressionNode -> return if (baseType is StringType || baseType is ListType || baseType is MapType) createSize( - expression - ) else createRange(expression) + is RangeExpressionNode -> return if (baseType is StringType || baseType is ListType || baseType is MapType) { + createSize(expression = expression, argument = expression) + } else { + createRange(expression = expression, argument = expression) + } is NumberNode -> { return if (expression is IntegerNode && (baseType is StringType || baseType is ListType || baseType is MapType)) { ResolvedTypeReference.Constraint.Size( - definition = expression, + node = expression, lowerBound = null, upperBound = expression.value ) } else { ResolvedTypeReference.Constraint.Range( - definition = expression, + node = expression, lowerBound = null, upperBound = expression.value ) } } - is StringNode -> return createPattern(expression) + is StringNode -> return createPattern(expression = expression, argument = expression) else -> Unit } - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Invalid constraint") highlight("invalid constraint", expression.location) } @@ -230,7 +247,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { return if (validateConstraintMatches(constraint, baseType)) { constraint } else { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Constraint '${constraint.humanReadableName}' is not allowed for type '${baseType.humanReadableName}'") highlight("illegal constraint", expression.location) @@ -256,4 +273,4 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } } -} \ No newline at end of file +} diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt index d1020a83..8258ef55 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt @@ -12,7 +12,7 @@ import tools.samt.parser.* * - Resolve all references to types * - Resolve all references to their declarations in the AST * */ -class SemanticModelBuilder( +class SemanticModelBuilder private constructor( private val files: List, private val controller: DiagnosticController, ) { @@ -29,7 +29,7 @@ class SemanticModelBuilder( block() } else { val existingType = parentPackage.types.getValue(statement.name.name) - controller.createContext(statement.location.source).error { + controller.getOrCreateContext(statement.location.source).error { message("'${statement.name.name}' is already declared") highlight("duplicate declaration", statement.name.location) if (existingType is UserDefinedType) { @@ -49,7 +49,7 @@ class SemanticModelBuilder( val name = identifierGetter(item).name val existingLocation = existingItems.putIfAbsent(name, item.location) if (existingLocation != null) { - controller.createContext(item.location.source).error { + controller.getOrCreateContext(item.location.source).error { message("$what '$name' is defined more than once") highlight("duplicate declaration", identifierGetter(item).location) highlight("previous declaration", existingLocation) @@ -88,7 +88,7 @@ class SemanticModelBuilder( ensureNameIsAvailable(parentPackage, statement) { reportDuplicates(statement.fields, "Record field") { it.name } if (statement.extends.isNotEmpty()) { - controller.createContext(statement.location.source).error { + controller.getOrCreateContext(statement.location.source).error { message("Record extends are not yet supported") highlight("cannot extend other records", statement.extends.first().location) } @@ -129,7 +129,7 @@ class SemanticModelBuilder( is RequestResponseOperationNode -> { if (operation.isAsync) { - controller.createContext(operation.location.source).error { + controller.getOrCreateContext(operation.location.source).error { message("Async operations are not yet supported") highlight("unsupported async operation", operation.location) } @@ -179,7 +179,7 @@ class SemanticModelBuilder( } is TypeAliasNode -> { - controller.createContext(statement.location.source).error { + controller.getOrCreateContext(statement.location.source).error { message("Type aliases are not yet supported") highlight("unsupported feature", statement.location) } @@ -246,7 +246,7 @@ class SemanticModelBuilder( } null -> { - controller.createContext(component.location.source).error { + controller.getOrCreateContext(component.location.source).error { message("Could not resolve reference '${component.name}'") highlight("unresolved reference", component.location) } @@ -257,7 +257,7 @@ class SemanticModelBuilder( if (iterator.hasNext()) { // We resolved a non-package type but there are still components left - controller.createContext(component.location.source).error { + controller.getOrCreateContext(component.location.source).error { message("Type '${component.name}' is not a package, cannot access sub-types") highlight("must be a package", component.location) } @@ -285,7 +285,7 @@ class SemanticModelBuilder( file.imports.forEach { import -> fun addImportedType(name: String, type: Type) { putIfAbsent(name, type)?.let { existingType -> - controller.createContext(file.sourceFile).error { + controller.getOrCreateContext(file.sourceFile).error { message("Import '$name' conflicts with locally defined type with same name") highlight("conflicting import", import.location) if (existingType is UserDefinedType) { @@ -318,7 +318,7 @@ class SemanticModelBuilder( addImportedType(name, type) } } else { - controller.createContext(file.sourceFile).error { + controller.getOrCreateContext(file.sourceFile).error { message("Import '${import.name.name}.*' must point to a package and not a type") highlight( "illegal wildcard import", import.location, suggestChange = "import ${ @@ -338,7 +338,7 @@ class SemanticModelBuilder( // Add built-in types fun addBuiltIn(name: String, type: Type) { putIfAbsent(name, type)?.let { existingType -> - controller.createContext(file.sourceFile).error { + controller.getOrCreateContext(file.sourceFile).error { message("Type '$name' shadows built-in type with same name") if (existingType is UserDefinedType) { val definition = existingType.definition @@ -379,7 +379,7 @@ class SemanticModelBuilder( return ResolvedTypeReference(expression, it) } - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Type '${expression.name}' could not be resolved") highlight("unresolved type", expression.location) } @@ -403,14 +403,14 @@ class SemanticModelBuilder( } null -> { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Type '${expression.name}' could not be resolved") highlight("unresolved type", expression.location) } } else -> { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Type '${expression.components.first().name}' is not a package, cannot access sub-types") highlight("not a package", expression.components.first().location) } @@ -422,7 +422,7 @@ class SemanticModelBuilder( val baseType = resolveExpression(expression.base) val constraints = expression.arguments.mapNotNull { constraintBuilder.build(baseType.type, it) } if (baseType.constraints.isNotEmpty()) { - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Cannot have nested constraints") highlight("illegal nested constraint", expression.location) } @@ -460,7 +460,7 @@ class SemanticModelBuilder( } } } - controller.createContext(expression.location.source).error { + controller.getOrCreateContext(expression.location.source).error { message("Unsupported generic type") highlight(expression.location) help("Valid generic types are List and Map") @@ -470,7 +470,7 @@ class SemanticModelBuilder( is OptionalDeclarationNode -> { val baseType = resolveExpression(expression.base) if (baseType.isOptional) { - controller.createContext(expression.location.source).warn { + controller.getOrCreateContext(expression.location.source).warn { message("Type is already optional, ignoring '?'") highlight("already optional", expression.base.location) } @@ -481,7 +481,7 @@ class SemanticModelBuilder( is BooleanNode, is NumberNode, is StringNode, - -> controller.createContext(expression.location.source).error { + -> controller.getOrCreateContext(expression.location.source).error { message("Cannot use literal value as type") highlight("not a type expression", expression.location) } @@ -490,7 +490,7 @@ class SemanticModelBuilder( is ArrayNode, is RangeExpressionNode, is WildcardNode, - -> controller.createContext(expression.location.source).error { + -> controller.getOrCreateContext(expression.location.source).error { message("Invalid type expression") highlight("not a type expression", expression.location) } @@ -505,7 +505,7 @@ class SemanticModelBuilder( companion object { fun build(files: List, controller: DiagnosticController): Package { // Sort by path to ensure deterministic order - return SemanticModelBuilder(files.sortedBy { it.sourceFile.absolutePath }, controller).build() + return SemanticModelBuilder(files.sortedBy { it.sourceFile.path }, controller).build() } } } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt index 8a8178c2..14e056b3 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt @@ -27,7 +27,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont check(typeReference is ResolvedTypeReference) when (val type = typeReference.type) { is ServiceType -> { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { // error message applies to both record fields and return types message("Cannot use service '${type.name}' as type") highlight("service type not allowed here", typeReference.definition.location) @@ -35,14 +35,14 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont } is ProviderType -> { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot use provider '${type.name}' as type") highlight("provider type not allowed here", typeReference.definition.location) } } is PackageType -> { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot use package '${type.packageName}' as type") highlight("package type not allowed here", typeReference.definition.location) } @@ -64,15 +64,15 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont private inline fun checkServiceType(typeReference: TypeReference, block: (serviceType: ServiceType) -> Unit) { check(typeReference is ResolvedTypeReference) if (typeReference.constraints.isNotEmpty()) { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot have constraints on service") for (constraint in typeReference.constraints) { - highlight("illegal constraint", constraint.definition.location) + highlight("illegal constraint", constraint.node.location) } } } if (typeReference.isOptional) { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot have optional service") highlight("illegal optional", typeReference.definition.location) } @@ -84,7 +84,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont is UnknownType -> Unit else -> { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Expected a service but got '${type.humanReadableName}'") highlight("illegal type", typeReference.definition.location) } @@ -95,15 +95,15 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont private inline fun checkProviderType(typeReference: TypeReference, block: (providerType: ProviderType) -> Unit) { check(typeReference is ResolvedTypeReference) if (typeReference.constraints.isNotEmpty()) { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot have constraints on provider") for (constraint in typeReference.constraints) { - highlight("illegal constraint", constraint.definition.location) + highlight("illegal constraint", constraint.node.location) } } } if (typeReference.isOptional) { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Cannot have optional provider") highlight("illegal optional", typeReference.definition.location) } @@ -115,7 +115,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont is UnknownType -> Unit else -> { - controller.createContext(typeReference.definition.location.source).error { + controller.getOrCreateContext(typeReference.definition.location.source).error { message("Expected a provider but got '${type.humanReadableName}'") highlight("illegal type", typeReference.definition.location) } @@ -144,7 +144,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont provider.implements.forEach { implements -> checkServiceType(implements.service) { type -> implementsTypes.putIfAbsent(type, implements.definition.location)?.let { existingLocation -> - controller.createContext(implements.definition.location.source).error { + controller.getOrCreateContext(implements.definition.location.source).error { message("Service '${type.name}' already implemented") highlight("duplicate implements", implements.definition.location) highlight("previous implements", existingLocation) @@ -160,7 +160,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont if (matchingOperation != null) { matchingOperation } else { - controller.createContext(provider.definition.location.source).error { + controller.getOrCreateContext(provider.definition.location.source).error { message("Operation '${serviceOperationName.name}' not found in service '${type.name}'") highlight("unknown operation", serviceOperationName.location) } @@ -178,7 +178,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont consumer.uses.forEach { uses -> checkServiceType(uses.service) { type -> usesTypes.putIfAbsent(type, uses.definition.location)?.let { existingLocation -> - controller.createContext(uses.definition.location.source).error { + controller.getOrCreateContext(uses.definition.location.source).error { message("Service '${type.name}' already used") highlight("duplicate uses", uses.definition.location) highlight("previous uses", existingLocation) @@ -189,7 +189,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont val matchingImplements = providerType.implements.find { (it.service as ResolvedTypeReference).type == type } if (matchingImplements == null) { - controller.createContext(uses.definition.location.source).error { + controller.getOrCreateContext(uses.definition.location.source).error { message("Service '${type.name}' is not implemented by provider '${providerType.name}'") highlight("unavailable service", uses.definition.serviceName.location) } @@ -205,12 +205,12 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont matchingOperation } else { if (type.operations.any { it.name == serviceOperationName.name }) { - controller.createContext(uses.definition.location.source).error { + controller.getOrCreateContext(uses.definition.location.source).error { message("Operation '${serviceOperationName.name}' in service '${type.name}' is not implemented by provider '${providerType.name}'") highlight("unavailable operation", serviceOperationName.location) } } else { - controller.createContext(uses.definition.location.source).error { + controller.getOrCreateContext(uses.definition.location.source).error { message("Operation '${serviceOperationName.name}' not found in service '${type.name}'") highlight("unknown operation", serviceOperationName.location) } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt index f4f7d05d..99e82750 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt @@ -249,10 +249,10 @@ data class ResolvedTypeReference( sealed interface Constraint { val humanReadableName: String - val definition: ExpressionNode + val node: ExpressionNode data class Range( - override val definition: ExpressionNode, + override val node: ExpressionNode, val lowerBound: Number?, val upperBound: Number?, ) : Constraint { @@ -261,7 +261,7 @@ data class ResolvedTypeReference( } data class Size( - override val definition: ExpressionNode, + override val node: ExpressionNode, val lowerBound: Long?, val upperBound: Long?, ) : Constraint { @@ -270,7 +270,7 @@ data class ResolvedTypeReference( } data class Pattern( - override val definition: ExpressionNode, + override val node: ExpressionNode, val pattern: String, ) : Constraint { override val humanReadableName: String @@ -278,7 +278,7 @@ data class ResolvedTypeReference( } data class Value( - override val definition: ExpressionNode, + override val node: ExpressionNode, val value: Any, ) : Constraint { override val humanReadableName: String diff --git a/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt b/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt index 879375b4..b9bd330c 100644 --- a/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt +++ b/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt @@ -5,6 +5,7 @@ import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile import tools.samt.lexer.Lexer import tools.samt.parser.Parser +import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -692,11 +693,11 @@ class SemanticModelTest { private fun parseAndCheck( vararg sourceAndExpectedMessages: Pair>, ) { - val diagnosticController = DiagnosticController("/tmp") - val fileTrees = sourceAndExpectedMessages.mapIndexed { index, (source) -> - val filePath = "/tmp/SemanticModelTest-${index}.samt" + val diagnosticController = DiagnosticController(URI("file:///tmp")) + val fileTree = sourceAndExpectedMessages.mapIndexed { index, (source) -> + val filePath = URI("file:///tmp/SemanticModelTest-${index}.samt") val sourceFile = SourceFile(filePath, source) - val parseContext = diagnosticController.createContext(sourceFile) + val parseContext = diagnosticController.getOrCreateContext(sourceFile) val stream = Lexer.scan(source.reader(), parseContext) val fileTree = Parser.parse(sourceFile, stream, parseContext) assertFalse(parseContext.hasErrors(), "Expected no parse errors, but had errors: ${parseContext.messages}}") @@ -705,7 +706,7 @@ class SemanticModelTest { val parseMessageCount = diagnosticController.contexts.associate { it.source.content to it.messages.size } - SemanticModelBuilder.build(fileTrees, diagnosticController) + SemanticModelBuilder.build(fileTree, diagnosticController) for ((source, expectedMessages) in sourceAndExpectedMessages) { val messages = diagnosticController.contexts diff --git a/settings.gradle.kts b/settings.gradle.kts index 8858a6d0..48d9739a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,12 @@ rootProject.name = "samt-core" -include(":common") -include(":cli") -include(":lexer") -include(":parser") -include(":semantic") +include( + ":common", + ":cli", + ":lexer", + ":parser", + ":semantic", + ":language-server" +) dependencyResolutionManagement { versionCatalogs { @@ -13,12 +16,14 @@ dependencyResolutionManagement { val mordant = "2.0.0-beta12" val kotlinxSerialization = "1.5.0" val kover = "0.6.1" + val lsp4j = "0.20.1" create("libs") { version("kotlin", kotlin) library("jCommander", "com.beust", "jcommander").version(jCommander) library("mordant", "com.github.ajalt.mordant", "mordant").version(mordant) library("kotlinx.serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version(kotlinxSerialization) + library("lsp4j", "org.eclipse.lsp4j", "org.eclipse.lsp4j").version(lsp4j) plugin("shadow", "com.github.johnrengelman.shadow").version(shadow) plugin("kover", "org.jetbrains.kotlinx.kover").version(kover)