From 11793642c15676a45e97c81e597b3ccd215cf5d6 Mon Sep 17 00:00:00 2001 From: HAHWUL Date: Sun, 26 May 2024 22:16:00 +0900 Subject: [PATCH] Revert "feat: Migrate and Enhance Spring Analyzer for Kotlin (#251)" --- README.md | 4 +- .../fixtures/kotlin_spring/build.gradle.kts | 480 ++++++++++- .../kotlin_spring/settings.gradle.kts | 6 - .../kotlin_spring/src/UserController.kt | 214 +++++ .../com/example/blog/BlogApplication.kt | 13 - .../com/example/blog/BlogConfiguration.kt | 28 - .../kotlin/com/example/blog/BlogProperties.kt | 8 - .../main/kotlin/com/example/blog/Entities.kt | 23 - .../kotlin/com/example/blog/Extensions.kt | 33 - .../kotlin/com/example/blog/HtmlController.kt | 159 ---- .../com/example/blog/HttpControllers.kt | 29 - .../kotlin/com/example/blog/Repositories.kt | 13 - .../src/main/resources/application.properties | 6 - .../main/resources/templates/article.mustache | 16 - .../main/resources/templates/blog.mustache | 31 - .../main/resources/templates/footer.mustache | 2 - .../main/resources/templates/header.mustache | 5 - .../resources/templates/response.mustache | 5 - .../testers/kotlin_spring_spec.cr | 44 +- .../analyzer/analyzer_kotlin_spring_spec.cr | 86 ++ .../detector/detect_java_spring_spec.cr | 7 +- .../detector/detect_kotlin_spring_spe_spec.cr | 6 +- .../analyzers/analyzer_java_spring.cr | 5 +- .../analyzers/analyzer_kotlin_spring.cr | 598 +++++--------- src/detector/detectors/java_spring.cr | 4 +- src/detector/detectors/kotlin_spring.cr | 4 +- src/minilexers/java.cr | 300 +++---- src/minilexers/kotlin.cr | 245 ------ src/miniparsers/kotlin.cr | 758 ------------------ src/models/endpoint.cr | 4 - 30 files changed, 1104 insertions(+), 2032 deletions(-) delete mode 100644 spec/functional_test/fixtures/kotlin_spring/settings.gradle.kts create mode 100644 spec/functional_test/fixtures/kotlin_spring/src/UserController.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogApplication.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogConfiguration.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogProperties.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Entities.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Extensions.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HtmlController.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HttpControllers.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Repositories.kt delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/application.properties delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/article.mustache delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/blog.mustache delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/footer.mustache delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/header.mustache delete mode 100644 spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/response.mustache create mode 100644 spec/unit_test/analyzer/analyzer_kotlin_spring_spec.cr delete mode 100644 src/minilexers/kotlin.cr delete mode 100644 src/miniparsers/kotlin.cr diff --git a/README.md b/README.md index b750f1d6..b387f850 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ | Php | | ✅ | ✅ | ✅ | ✅ | X | X | | Java | Jsp | ✅ | ✅ | ✅ | X | X | X | | Java | Armeria | ✅ | ✅ | X | X | X | X | -| Java | Spring | ✅ | ✅ | ✅ | ✅ | X | X | -| Kotlin | Spring | ✅ | ✅ | ✅ | ✅ | ✅ | X | +| Java | Spring | ✅ | ✅ | X | X | X | X | +| Kotlin | Spring | ✅ | ✅ | ✅ | X | X | X | | JS | Express | ✅ | ✅ | ✅ | ✅ | ✅ | X | | JS | Restify | ✅ | ✅ | ✅ | ✅ | ✅ | X | | Rust | Axum | ✅ | ✅ | X | X | X | X | diff --git a/spec/functional_test/fixtures/kotlin_spring/build.gradle.kts b/spec/functional_test/fixtures/kotlin_spring/build.gradle.kts index c5818b24..aa14c5ed 100644 --- a/spec/functional_test/fixtures/kotlin_spring/build.gradle.kts +++ b/spec/functional_test/fixtures/kotlin_spring/build.gradle.kts @@ -1,53 +1,447 @@ +import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jreleaser.model.Active +import org.jreleaser.model.Distribution.DistributionType.SINGLE_JAR +import org.jreleaser.model.api.common.Apply plugins { - id("org.springframework.boot") version "3.2.2" - id("io.spring.dependency-management") version "1.1.4" - kotlin("jvm") version "1.9.22" - kotlin("plugin.spring") version "1.9.22" - kotlin("plugin.allopen") version "1.9.22" - kotlin("plugin.jpa") version "1.9.22" - kotlin("kapt") version "1.9.22" + run { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("kapt") + } + id("org.springframework.boot") version "3.1.1" + id("com.gorylenko.gradle-git-properties") version "2.4.1" + id("nu.studer.jooq") version "8.2.1" + id("org.flywaydb.flyway") version "9.7.0" + id("com.github.johnrengelman.processes") version "0.5.0" + id("org.springdoc.openapi-gradle-plugin") version "1.6.0" + id("org.jreleaser") version "1.6.0" + id("com.google.devtools.ksp") version "1.8.22-1.0.11" + + jacoco } -group = "com.example" -version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 +group = "org.gotson" -repositories { - mavenCentral() +val benchmarkSourceSet = sourceSets.create("benchmark") { + java { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().runtimeClasspath + } +} + +val benchmarkImplementation by configurations.getting { + extendsFrom(configurations.testImplementation.get()) +} +val kaptBenchmark by configurations.getting { + extendsFrom(configurations.kaptTest.get()) } dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-mustache") - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlin:kotlin-reflect") - runtimeOnly("com.h2database:h2") - runtimeOnly("org.springframework.boot:spring-boot-devtools") - kapt("org.springframework.boot:spring-boot-configuration-processor") - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testImplementation("org.junit.jupiter:junit-jupiter-api") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") - testImplementation("com.ninja-squad:springmockk:4.0.2") -} - -tasks.withType { - kotlinOptions { - jvmTarget = "17" - freeCompilerArgs += "-Xjsr305=strict" - } -} - -allOpen { - annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.Embeddable") - annotation("jakarta.persistence.MappedSuperclass") -} - -tasks.withType { - useJUnitPlatform() + implementation(kotlin("stdlib")) + implementation(kotlin("reflect")) + + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.1")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-artemis") + implementation("org.springframework.boot:spring-boot-starter-jooq") + implementation("org.springframework.session:spring-session-core") + implementation("com.github.gotson:spring-session-caffeine:2.0.0") + implementation("org.springframework.data:spring-data-commons") + + kapt("org.springframework.boot:spring-boot-configuration-processor:3.1.1") + + implementation("org.apache.activemq:artemis-jakarta-server") + + implementation("org.flywaydb:flyway-core") + + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation("io.hawt:hawtio-springboot:2.17.4") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + + implementation("commons-io:commons-io:2.13.0") + implementation("org.apache.commons:commons-lang3:3.12.0") + implementation("commons-validator:commons-validator:1.7") + + run { + val luceneVersion = "9.7.0" + implementation("org.apache.lucene:lucene-core:$luceneVersion") + implementation("org.apache.lucene:lucene-analysis-common:$luceneVersion") + implementation("org.apache.lucene:lucene-queryparser:$luceneVersion") + implementation("org.apache.lucene:lucene-backward-codecs:$luceneVersion") + } + + implementation("com.ibm.icu:icu4j:73.2") + + implementation("com.appmattus.crypto:cryptohash:0.10.1") + + implementation("org.apache.tika:tika-core:2.8.0") + implementation("org.apache.commons:commons-compress:1.23.0") + implementation("com.github.junrar:junrar:7.5.4") + implementation("org.apache.pdfbox:pdfbox:2.0.28") + implementation("net.grey-panther:natural-comparator:1.1") + implementation("org.jsoup:jsoup:1.16.1") + + implementation("net.coobird:thumbnailator:0.4.19") + runtimeOnly("com.twelvemonkeys.imageio:imageio-jpeg:3.9.4") + runtimeOnly("com.twelvemonkeys.imageio:imageio-tiff:3.9.4") + runtimeOnly("com.twelvemonkeys.imageio:imageio-webp:3.9.4") + runtimeOnly("com.github.gotson.nightmonkeys:imageio-jxl:0.4.1") + // support for jpeg2000 + runtimeOnly("com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0") + runtimeOnly("org.apache.pdfbox:jbig2-imageio:3.0.4") + + // barcode scanning + implementation("com.google.zxing:core:3.5.1") + + implementation("com.jakewharton.byteunits:byteunits:0.9.1") + + implementation("com.github.f4b6a3:tsid-creator:5.2.4") + + implementation("com.github.ben-manes.caffeine:caffeine") + + implementation("org.xerial:sqlite-jdbc:3.42.0.0") + jooqGenerator("org.xerial:sqlite-jdbc:3.42.0.0") + + if (version.toString().endsWith(".0.0")) { + ksp("com.github.gotson.bestbefore:bestbefore-processor-kotlin:0.1.0") + } + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(module = "mockito-core") + } + testImplementation("org.springframework.security:spring-security-test") + testImplementation("com.ninja-squad:springmockk:4.0.2") + testImplementation("io.mockk:mockk:1.13.5") + testImplementation("com.google.jimfs:jimfs:1.2") + + testImplementation("com.tngtech.archunit:archunit-junit5:1.0.1") + + benchmarkImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") + benchmarkImplementation("org.openjdk.jmh:jmh-core:1.36") + kaptBenchmark("org.openjdk.jmh:jmh-generator-annprocess:1.36") + kaptBenchmark("org.springframework.boot:spring-boot-configuration-processor:3.1.1") + + developmentOnly("org.springframework.boot:spring-boot-devtools:3.1.1") +} + +val webui = "$rootDir/komga-webui" +tasks { + withType { + sourceCompatibility = "17" + targetCompatibility = "17" + } + withType { + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs = listOf( + "-Xjsr305=strict", + "-opt-in=kotlin.time.ExperimentalTime", + ) + } + } + + withType { + useJUnitPlatform() + systemProperty("spring.profiles.active", "test") + maxHeapSize = "1G" + } + + getByName("jar") { + enabled = false + } + + register("npmInstall") { + group = "web" + workingDir(webui) + inputs.file("$webui/package.json") + outputs.dir("$webui/node_modules") + commandLine( + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + "npm.cmd" + } else { + "npm" + }, + "install", + ) + } + + register("npmBuild") { + group = "web" + dependsOn("npmInstall") + workingDir(webui) + inputs.dir(webui) + outputs.dir("$webui/dist") + commandLine( + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + "npm.cmd" + } else { + "npm" + }, + "run", + "build", + ) + } + + // copy the webui build into public + register("copyWebDist") { + group = "web" + dependsOn("npmBuild") + from("$webui/dist/") + into("$projectDir/src/main/resources/public/") + } + + withType { + filesMatching("application*.yml") { + expand(project.properties) + } + mustRunAfter(getByName("copyWebDist")) + } + + register("benchmark") { + group = "benchmark" + inputs.files(benchmarkSourceSet.output) + testClassesDirs = benchmarkSourceSet.output.classesDirs + classpath = benchmarkSourceSet.runtimeClasspath + } +} + +springBoot { + buildInfo { + // prevent task bootBuildInfo to rerun every time + excludes.set(setOf("time")) + properties { + // but rerun if the gradle.properties file changed + inputs.file("$rootDir/gradle.properties") + } + } +} + +sourceSets { + // add a flyway sourceSet + val flyway by creating { + compileClasspath += sourceSets.main.get().compileClasspath + runtimeClasspath += sourceSets.main.get().runtimeClasspath + } + // main sourceSet depends on the output of flyway sourceSet + main { + output.dir(flyway.output) + } +} + +val dbSqlite = mapOf( + "url" to "jdbc:sqlite:${project.buildDir}/generated/flyway/database.sqlite", +) +val migrationDirsSqlite = listOf( + "$projectDir/src/flyway/resources/db/migration/sqlite", + "$projectDir/src/flyway/kotlin/db/migration/sqlite", +) +flyway { + url = dbSqlite["url"] + locations = arrayOf("classpath:db/migration/sqlite") + placeholders = mapOf("library-file-hashing" to "true") +} +tasks.flywayMigrate { + // in order to include the Java migrations, flywayClasses must be run before flywayMigrate + dependsOn("flywayClasses") + migrationDirsSqlite.forEach { inputs.dir(it) } + outputs.dir("${project.buildDir}/generated/flyway") + doFirst { + delete(outputs.files) + mkdir("${project.buildDir}/generated/flyway") + } + mixed = true +} + +jooq { + version.set("3.17.4") + configurations { + create("main") { + jooqConfiguration.apply { + logging = org.jooq.meta.jaxb.Logging.WARN + jdbc.apply { + driver = "org.sqlite.JDBC" + url = dbSqlite["url"] + } + generator.apply { + database.apply { + name = "org.jooq.meta.sqlite.SQLiteDatabase" + } + target.apply { + packageName = "org.gotson.komga.jooq" + } + } + } + } + } +} +tasks.named("generateJooq") { + migrationDirsSqlite.forEach { inputs.dir(it) } + allInputsDeclared.set(true) + dependsOn("flywayMigrate") +} + +openApi { + outputDir.set(file("$projectDir/docs")) + customBootRun { + args.add("--spring.profiles.active=claim") + args.add("--server.port=8080") + } +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) +} + +configure { + filter { + exclude("**/db/migration/**") + } +} + +jreleaser { + gitRootSearch.set(true) + + project { + description.set("Media server for comics/mangas/BDs with API and OPDS support") + copyright.set("Gauthier Roebroeck") + authors.add("Gauthier Roebroeck") + license.set("MIT") + links { + homepage.set("https://komga.org") + } + } + + release { + github { + discussionCategoryName.set("Announcements") + + changelog { + formatted.set(Active.ALWAYS) + preset.set("conventional-commits") + skipMergeCommits.set(true) + links.set(true) + format.set("- {{#commitIsConventional}}{{#conventionalCommitIsBreakingChange}}🚨 {{/conventionalCommitIsBreakingChange}}{{#conventionalCommitScope}}**{{conventionalCommitScope}}**: {{/conventionalCommitScope}}{{conventionalCommitDescription}}{{#conventionalCommitBreakingChangeContent}}: *{{conventionalCommitBreakingChangeContent}}*{{/conventionalCommitBreakingChangeContent}} ({{commitShortHash}}){{/commitIsConventional}}{{^commitIsConventional}}{{commitTitle}} ({{commitShortHash}}){{/commitIsConventional}}{{#commitHasIssues}}, closes{{#commitIssues}} {{issue}}{{/commitIssues}}{{/commitHasIssues}}") + hide { + uncategorized.set(true) + contributors.set(listOf("Weblate", "GitHub", "semantic-release-bot", "[bot]", "github-actions")) + } + excludeLabels.add("chore") + category { + title.set("🏎 Perf") + key.set("perf") + labels.add("perf") + order.set(25) + } + category { + title.set("🌐 Translation") + key.set("i18n") + labels.add("i18n") + order.set(70) + } + labeler { + label.set("perf") + title.set("regex:^(?:perf(?:\\(.*\\))?!?):\\s.*") + order.set(120) + } + labeler { + label.set("i18n") + title.set("regex:^(?:i18n(?:\\(.*\\))?!?):\\s.*") + order.set(130) + } + extraProperties.put("categorizeScopes", true) + append { + enabled.set(true) + title.set("# [{{projectVersion}}]({{repoUrl}}/compare/{{previousTagName}}...{{tagName}}) ({{#f_now}}YYYY-MM-dd{{/f_now}})") + target.set(rootDir.resolve("CHANGELOG.md")) + content.set( + """ + {{changelogTitle}} + {{changelogChanges}} + """.trimIndent(), + ) + } + } + + issues { + enabled.set(true) + comment.set("🎉 This issue has been resolved in `{{tagName}}` ([Release Notes]({{releaseNotesUrl}}))") + applyMilestone.set(Apply.ALWAYS) + label { + name.set("released") + description.set("Issue has been released") + color.set("#ededed") + } + } + } + } + + checksum.individual.set(true) + + distributions { + create("komga") { + distributionType.set(SINGLE_JAR) + artifact { + path.set(files(tasks.bootJar).singleFile) + } + } + } + + packagers { + docker { + active.set(Active.RELEASE) + continueOnError.set(true) + templateDirectory.set(projectDir.resolve("docker")) + repository.active.set(Active.NEVER) + buildArgs.set(listOf("--cache-from", "gotson/komga:latest")) + imageNames.set( + listOf( + "komga:latest", + "komga:{{projectVersion}}", + "komga:{{projectVersionMajor}}.x", + ), + ) + registries { + create("docker.io") { externalLogin.set(true) } + create("ghcr.io") { externalLogin.set(true) } + } + buildx { + enabled.set(true) + createBuilder.set(false) + platforms.set( + listOf( + "linux/amd64", + "linux/arm/v7", + "linux/arm64/v8", + ), + ) + } + } + } +} + +project.afterEvaluate { + tasks.named("forkedSpringBootRun") { + mustRunAfter(tasks.bootJar) + } +} + +tasks.jreleaserPackage { + inputs.files(tasks.bootJar) } +// Workaround for https://github.com/jreleaser/jreleaser/issues/1231 +tasks.jreleaserFullRelease { + inputs.files(tasks.bootJar) +} \ No newline at end of file diff --git a/spec/functional_test/fixtures/kotlin_spring/settings.gradle.kts b/spec/functional_test/fixtures/kotlin_spring/settings.gradle.kts deleted file mode 100644 index 0fc79bce..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/settings.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -pluginManagement { - repositories { - gradlePluginPortal() - } -} -rootProject.name = "blog" \ No newline at end of file diff --git a/spec/functional_test/fixtures/kotlin_spring/src/UserController.kt b/spec/functional_test/fixtures/kotlin_spring/src/UserController.kt new file mode 100644 index 00000000..4b8d0755 --- /dev/null +++ b/spec/functional_test/fixtures/kotlin_spring/src/UserController.kt @@ -0,0 +1,214 @@ +// https://github.com/gotson/komga/blob/e50591f372f0ac077bbaf730b1439220a32608af/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Parameter +import jakarta.validation.Valid +import mu.KotlinLogging +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto +import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto +import org.gotson.komga.interfaces.api.rest.dto.UserDto +import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springdoc.core.converters.models.PageableAsQueryParam +import org.springframework.core.env.Environment +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("/api/v2/users", produces = [MediaType.APPLICATION_JSON_VALUE]) +class UserController( + private val userLifecycle: KomgaUserLifecycle, + private val userRepository: KomgaUserRepository, + private val libraryRepository: LibraryRepository, + private val authenticationActivityRepository: AuthenticationActivityRepository, + env: Environment, +) { + + private val demo = env.activeProfiles.contains("demo") + + @GetMapping("/me") + fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDto = + principal.toDto() + + @PatchMapping("/me/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateMyPassword( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody + newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByEmailIgnoreCaseOrNull(principal.username)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, false) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun getAll(): List = + userRepository.findAll().map { it.toDto() } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun addOne( + @Valid @RequestBody + newUser: UserCreationDto, + ): UserDto = + try { + userLifecycle.createUser(newUser.toDomain()).toDto() + } catch (e: UserEmailAlreadyExistsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + fun delete( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { + userLifecycle.deleteUser(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + fun updateUser( + @PathVariable id: String, + @Valid @RequestBody + patch: UserUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { existing -> + val updatedUser = with(patch) { + existing.copy( + roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin, + roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload, + rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming, + sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries, + sharedLibrariesIds = if (isSet("sharedLibraries")) { + if (sharedLibraries!!.all) emptySet() + else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet() + } else existing.sharedLibrariesIds, + restrictions = ContentRestrictions( + ageRestriction = if (isSet("ageRestriction")) { + if (ageRestriction == null) null + else AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction) + } else existing.restrictions.ageRestriction, + labelsAllow = if (isSet("labelsAllow")) labelsAllow + ?: emptySet() else existing.restrictions.labelsAllow, + labelsExclude = if (isSet("labelsExclude")) labelsExclude + ?: emptySet() else existing.restrictions.labelsExclude, + ), + ) + } + userLifecycle.updateUser(updatedUser) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("/{id}/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + fun updatePassword( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody + newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByIdOrNull(id)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, user.id != principal.user.id) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping("/me/authentication-activity") + @PageableAsQueryParam + fun getMyAuthenticationActivity( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + if (demo && !principal.user.roleAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN) + val sort = + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) UnpagedSorted(sort) + else PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() } + } + + @GetMapping("/authentication-activity") + @PageableAsQueryParam + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun getAuthenticationActivity( + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) UnpagedSorted(sort) + else PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() } + } + + @GetMapping("/{id}/authentication-activity/latest") + @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + fun getLatestAuthenticationActivityForUser( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ): AuthenticationActivityDto = + userRepository.findByIdOrNull(id)?.let { user -> + authenticationActivityRepository.findMostRecentByUser(user)?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} \ No newline at end of file diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogApplication.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogApplication.kt deleted file mode 100644 index e9f7593a..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.blog - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.runApplication - -@SpringBootApplication -@EnableConfigurationProperties(BlogProperties::class) -class BlogApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogConfiguration.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogConfiguration.kt deleted file mode 100644 index 05d7cefb..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogConfiguration.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.blog - -import org.springframework.boot.ApplicationRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -class BlogConfiguration { - - @Bean - fun databaseInitializer(userRepository: UserRepository, - articleRepository: ArticleRepository) = ApplicationRunner { - - val johnDoe = userRepository.save(User("johnDoe", "John", "Doe")) - articleRepository.save(Article( - title = "Lorem", - headline = "Lorem", - content = "dolor sit amet", - author = johnDoe - )) - articleRepository.save(Article( - title = "Ipsum", - headline = "Ipsum", - content = "dolor sit amet", - author = johnDoe - )) - } -} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogProperties.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogProperties.kt deleted file mode 100644 index 97eedfde..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/BlogProperties.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.blog - -import org.springframework.boot.context.properties.ConfigurationProperties - -@ConfigurationProperties("blog") -data class BlogProperties(var title: String, val banner: Banner) { - data class Banner(val title: String? = null, val content: String) -} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Entities.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Entities.kt deleted file mode 100644 index 9de2be97..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Entities.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.blog - -import java.time.LocalDateTime -import jakarta.persistence.* - -@Entity -class Article( - var title: String, - var headline: String = "", - var content: String, - @ManyToOne var author: User, - var slug: String = title.toSlug(), - var addedAt: LocalDateTime = LocalDateTime.now(), - var deleted: Boolean = false, - @Id @GeneratedValue var id: Long? = null) - -@Entity -class User( - var login: String, - var firstname: String, - var lastname: String, - var description: String? = null, - @Id @GeneratedValue var id: Long? = null) diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Extensions.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Extensions.kt deleted file mode 100644 index 047e4fa3..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Extensions.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.blog - -import java.time.LocalDateTime -import java.time.format.DateTimeFormatterBuilder -import java.time.temporal.ChronoField -import java.util.* - -fun LocalDateTime.format(): String = this.format(englishDateFormatter) - -private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) } - -private val englishDateFormatter = DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd") - .appendLiteral(" ") - .appendText(ChronoField.DAY_OF_MONTH, daysLookup) - .appendLiteral(" ") - .appendPattern("yyyy") - .toFormatter(Locale.ENGLISH) - -private fun getOrdinal(n: Int) = when { - n in 11..13 -> "${n}th" - n % 10 == 1 -> "${n}st" - n % 10 == 2 -> "${n}nd" - n % 10 == 3 -> "${n}rd" - else -> "${n}th" -} - -fun String.toSlug() = lowercase(Locale.getDefault()) - .replace("\n", " ") - .replace("[^a-z\\d\\s]".toRegex(), " ") - .split(" ") - .joinToString("-") - .replace("-+".toRegex(), "-") diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HtmlController.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HtmlController.kt deleted file mode 100644 index d1606495..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HtmlController.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.example.blog - -import org.springframework.http.HttpStatus.* -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.ui.set -import org.springframework.web.bind.annotation.* -import java.time.LocalDateTime -import org.springframework.web.server.ResponseStatusException - -@Controller -class HtmlController(private val repository: ArticleRepository, - private val properties: BlogProperties) { - - @GetMapping("/v1", params = ["version=1"]) - fun blogV1(model: Model): String { - model["title"] = "${properties.title} - v1" - model["banner"] = properties.banner - model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() } - return "blog" - } - - @GetMapping(path = ["/v2", "/version2"], params = ["version=2"]) - fun blogV2(model: Model): String { - model["title"] = "${properties.title} - v2" - model["banner"] = properties.banner - model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.renderV2() } - return "blog" - } - - @GetMapping(value = ["/v3", "/version3"], params = ["version=3"]) - fun blogV3(model: Model): String { - model["title"] = "${properties.title} - v3" - model["banner"] = properties.banner - model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.renderV2() } - return "blog" - } - - @PostMapping("/article", consumes = ["application/json"], produces = ["application/json"]) - fun createArticleJson(@RequestBody article: Article): ResponseEntity { - val savedArticle = repository.save(article) - return ResponseEntity(savedArticle.render(), CREATED) - } - - @PostMapping("/article2", consumes = ["application/x-www-form-urlencoded"]) - fun createArticleForm(@RequestParam title: String, @RequestParam content: String, model: Model): String { - val article = Article(title = title, content = content, author = User("authorLogin", "Author", "Lastname")) - repository.save(article) - model["message"] = "Article created" - return "response" - } - - @GetMapping("/article/{slug}") - fun article(@PathVariable slug: String, @RequestParam(defaultValue = "false") preview: Boolean, model: Model): String { - val article = repository - .findBySlug(slug) - ?.render() - ?: throw ResponseStatusException(NOT_FOUND, "This article does not exist") - model["title"] = article.title - model["article"] = article - model["preview"] = preview - return "article" - } - - @PutMapping("/article/{id}", consumes = ["application/json"]) - fun updateArticleJson(@PathVariable id: Long, @RequestBody updateData: UpdateData, model: Model): String { - val article = repository.findById(id).orElseThrow { ResponseStatusException(NOT_FOUND, "This article does not exist") } - article.title = updateData.title - article.content = updateData.content - repository.save(article) - model["message"] = "Article updated" - return "response" - } - - @DeleteMapping("/article/{id}", params = ["soft"], headers = ["X-Custom-Header=soft-delete"]) - fun softDeleteArticle(@PathVariable id: Long, model: Model): String { - val article = repository.findById(id).orElseThrow { ResponseStatusException(NOT_FOUND, "This article does not exist") } - article.deleted = true - repository.save(article) - model["message"] = "Article soft deleted" - return "response" - } - - @DeleteMapping("/article2/{id}") - fun deleteArticle(@PathVariable id: Long, model: Model): String { - repository.deleteById(id) - model["message"] = "Article deleted" - return "response" - } - - @PatchMapping("/article/{id}", consumes = arrayOf("application/json")) - fun patchArticleJson(@PathVariable id: Long, @RequestBody patchData: PatchData, model: Model): String { - val article = repository.findById(id).orElseThrow { ResponseStatusException(NOT_FOUND, "This article does not exist") } - article.title = patchData.title ?: article.title - article.content = patchData.content ?: article.content - repository.save(article) - model["message"] = "Article patched" - return "response" - } - - @RequestMapping("/request", method = [RequestMethod.GET, RequestMethod.POST], params = ["type=basic"], headers = ["X-Custom-Header=basic"]) - fun handleRequestBasic(model: Model): String { - model["message"] = "Handled by @RequestMapping with type=basic and custom header" - return "response" - } - - @RequestMapping("/request2", method = [RequestMethod.GET, RequestMethod.POST], params = ["type=advanced"], headers = ["X-Custom-Header=advanced"]) - fun handleRequestAdvanced(model: Model): String { - model["message"] = "Handled by @RequestMapping with type=advanced and custom header" - return "response" - } - - fun Article.render() = RenderedArticle( - slug, - title, - headline, - content, - author, - addedAt.format() - ) - - fun Article.renderV2() = RenderedArticleV2( - slug, - title, - headline, - content, - author, - addedAt.format() - ) - - data class RenderedArticle( - val slug: String, - val title: String, - val headline: String, - val content: String, - val author: User, - val addedAt: String - ) - - data class RenderedArticleV2( - val slug: String, - val title: String, - val headline: String, - val content: String, - val author: User, - val addedAt: String - ) - - data class UpdateData( - val title: String, - val content: String - ) - - data class PatchData( - val title: String?, - val content: String? - ) -} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HttpControllers.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HttpControllers.kt deleted file mode 100644 index 8d8bf3a1..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/HttpControllers.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.blog - -import org.springframework.http.HttpStatus.* -import org.springframework.web.bind.annotation.* -import org.springframework.web.server.ResponseStatusException - -@RestController -@RequestMapping("/api/article") -class ArticleController(private val repository: ArticleRepository) { - - @GetMapping("/") - fun findAll() = repository.findAllByOrderByAddedAtDesc() - - @GetMapping("/{slug}") - fun findOne(@PathVariable slug: String) = - repository.findBySlug(slug) ?: throw ResponseStatusException(NOT_FOUND, "This article does not exist") - -} - -@RestController -@RequestMapping("/api/user") -class UserController(private val repository: UserRepository) { - - @GetMapping("/") - fun findAll() = repository.findAll() - - @GetMapping("/{login}") - fun findOne(@PathVariable login: String, @CookieValue(name = "lorem", defaultValue = "lorem") lorem: String) = repository.findByLogin(login) ?: throw ResponseStatusException(NOT_FOUND, "This user does not exist ($lorem ipsum)") -} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Repositories.kt b/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Repositories.kt deleted file mode 100644 index 36f297a4..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/kotlin/com/example/blog/Repositories.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.blog - -import org.springframework.data.repository.CrudRepository - -interface ArticleRepository : CrudRepository { - fun findBySlug(slug: String): Article? - fun findAllByOrderByAddedAtDesc(): Iterable
-} - -interface UserRepository : CrudRepository { - fun findByLogin(login: String): User? -} - diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/application.properties b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/application.properties deleted file mode 100644 index 332f25ea..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/application.properties +++ /dev/null @@ -1,6 +0,0 @@ -blog.title=Blog -blog.banner.title=Warning -blog.banner.content=The blog will be down tomorrow. - -spring.jpa.properties.hibernate.globally_quoted_identifiers=true -spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions=true diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/article.mustache b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/article.mustache deleted file mode 100644 index 67238bb2..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/article.mustache +++ /dev/null @@ -1,16 +0,0 @@ -{{> header}} - -
-
-

{{article.title}}

- -
- -
- {{article.headline}} - - {{article.content}} -
-
- -{{> footer}} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/blog.mustache b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/blog.mustache deleted file mode 100644 index 3b02a2de..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/blog.mustache +++ /dev/null @@ -1,31 +0,0 @@ -{{> header}} - -

{{title}}

- -
- - {{#banner.title}} -
- - -
- {{/banner.title}} - - {{#articles}} -
-
-

{{title}}

- -
-
- {{headline}} -
-
- {{/articles}} -
- -{{> footer}} diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/footer.mustache b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/footer.mustache deleted file mode 100644 index 308b1d01..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/footer.mustache +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/header.mustache b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/header.mustache deleted file mode 100644 index b5efbf3f..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/header.mustache +++ /dev/null @@ -1,5 +0,0 @@ - - - {{title}} - - diff --git a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/response.mustache b/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/response.mustache deleted file mode 100644 index c52350a4..00000000 --- a/spec/functional_test/fixtures/kotlin_spring/src/main/resources/templates/response.mustache +++ /dev/null @@ -1,5 +0,0 @@ - - -

message: {{message}}

- - diff --git a/spec/functional_test/testers/kotlin_spring_spec.cr b/spec/functional_test/testers/kotlin_spring_spec.cr index 0f05e975..9337b180 100644 --- a/spec/functional_test/testers/kotlin_spring_spec.cr +++ b/spec/functional_test/testers/kotlin_spring_spec.cr @@ -1,41 +1,19 @@ require "../func_spec.cr" extected_endpoints = [ - Endpoint.new("/api/article/", "GET"), - Endpoint.new("/api/article/{slug}", "GET"), - Endpoint.new("/api/user/", "GET"), - Endpoint.new("/api/user/{login}", "GET", [Param.new("lorem", "ipsum", "cookie")]), - Endpoint.new("/v1", "GET", [Param.new("version", "1", "query")]), - Endpoint.new("/v2", "GET", [Param.new("version", "2", "query")]), - Endpoint.new("/version2", "GET", [Param.new("version", "2", "query")]), - Endpoint.new("/v3", "GET", [Param.new("version", "3", "query")]), - Endpoint.new("/version3", "GET", [Param.new("version", "3", "query")]), - Endpoint.new("/article", "POST", [ - Param.new("title", "", "json"), - Param.new("headline", "", "json"), - Param.new("content", "", "json"), - Param.new("login", "", "json"), - Param.new("firstname", "", "json"), - Param.new("lastname", "", "json"), - Param.new("description", "", "json"), - Param.new("id", "", "json"), - Param.new("slug", "", "json"), - Param.new("addedAt", "", "json"), - Param.new("deleted", "", "json"), - ]), - Endpoint.new("/article2", "POST", [Param.new("title", "", "query"), Param.new("content", "", "query")]), - Endpoint.new("/article/{slug}", "GET", [Param.new("preview", "false", "query")]), - Endpoint.new("/article/{id}", "PUT", [Param.new("title", "", "json"), Param.new("content", "", "json")]), - Endpoint.new("/article/{id}", "DELETE", [Param.new("soft", "", "form"), Param.new("X-Custom-Header", "soft-delete", "header")]), - Endpoint.new("/article2/{id}", "DELETE"), - Endpoint.new("/article/{id}", "PATCH", [Param.new("title", "", "json"), Param.new("content", "", "json")]), - Endpoint.new("/request", "GET", [Param.new("type", "basic", "query"), Param.new("X-Custom-Header", "basic", "header")]), - Endpoint.new("/request", "POST", [Param.new("type", "basic", "query"), Param.new("X-Custom-Header", "basic", "header")]), - Endpoint.new("/request2", "GET", [Param.new("type", "advanced", "query"), Param.new("X-Custom-Header", "advanced", "header")]), - Endpoint.new("/request2", "POST", [Param.new("type", "advanced", "query"), Param.new("X-Custom-Header", "advanced", "header")]), + Endpoint.new("/api/v2/users/me", "GET"), + Endpoint.new("/api/v2/users/me/password", "PATCH"), + Endpoint.new("/api/v2/users", "GET"), + Endpoint.new("/api/v2/users", "POST"), + Endpoint.new("/api/v2/users/{id}", "DELETE"), + Endpoint.new("/api/v2/users/{id}", "PATCH"), + Endpoint.new("/api/v2/users/{id}/password", "PATCH"), + Endpoint.new("/api/v2/users/me/authentication-activity", "GET"), + Endpoint.new("/api/v2/users/authentication-activity", "GET"), + Endpoint.new("/api/v2/users/{id}/authentication-activity/latest", "GET"), ] FunctionalTester.new("fixtures/kotlin_spring/", { :techs => 1, - :endpoints => 20, + :endpoints => 10, }, extected_endpoints).test_all diff --git a/spec/unit_test/analyzer/analyzer_kotlin_spring_spec.cr b/spec/unit_test/analyzer/analyzer_kotlin_spring_spec.cr new file mode 100644 index 00000000..a84241b1 --- /dev/null +++ b/spec/unit_test/analyzer/analyzer_kotlin_spring_spec.cr @@ -0,0 +1,86 @@ +require "../../../src/analyzer/analyzers/analyzer_kotlin_spring.cr" +require "../../../src/options" + +describe "mapping_to_path" do + options = default_options() + instance = AnalyzerKotlinSpring.new(options) + + it "mapping_to_path - GET" do + instance.mapping_to_path("@GetMapping(\"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - POST" do + instance.mapping_to_path("@PostMapping(\"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - PUT" do + instance.mapping_to_path("@PutMapping(\"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - DELETE" do + instance.mapping_to_path("@DeleteMapping(\"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - PATCH" do + instance.mapping_to_path("@PatchMapping(\"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - code style1" do + instance.mapping_to_path("@GetMapping(value = \"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - code style2" do + instance.mapping_to_path("@GetMapping({ \"/abcd\" })").should eq(["/abcd"]) + end + it "mapping_to_path - code style3" do + instance.mapping_to_path("@GetMapping(\"abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - code style4" do + instance.mapping_to_path("@GetMapping(value = \"abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - code style5" do + instance.mapping_to_path("@GetMapping({ \"abcd\" })").should eq(["/abcd"]) + end + it "mapping_to_path - multiple path" do + instance.mapping_to_path("@GetMapping(value={\"/abcd\", \"/efgh\"})").should eq(["/abcd", "/efgh"]) + end + it "mapping_to_path - url template style" do + instance.mapping_to_path("@GetMapping(\"/{abcd}\")").should eq(["/{abcd}"]) + end + it "mapping_to_path - ant-style" do + instance.mapping_to_path("@GetMapping(\"/{abcd:[a-z]+}\")").should eq(["/{abcd:[a-z]+}"]) + end + it "mapping_to_path - regular expression style" do + instance.mapping_to_path("@GetMapping(\"/{number:^[0-9]+$}\")").should eq(["/{number:^[0-9]+$}"]) + end + it "mapping_to_path - requestmapping style1" do + instance.mapping_to_path("@RequestMapping(value = \"/abcd\", method={RequestMethod.GET, RequestMethod.POST})").should eq(["/abcd"]) + end + it "mapping_to_path - requestmapping style2" do + instance.mapping_to_path("@RequestMapping(method={RequestMethod.GET, RequestMethod.POST}, value = \"/abcd\")").should eq(["/abcd"]) + end + it "mapping_to_path - requestmapping style3" do + instance.mapping_to_path("@RequestMapping(value = \"/0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\")").should eq(["/0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]) + end + it "mapping_to_path - requestmapping style4" do + instance.mapping_to_path("@GetMapping()").should eq([""]) + end + it "mapping_to_path - requestmapping style5" do + instance.mapping_to_path("@RequestMapping(method = RequestMethod.GET)").should eq([""]) + end + it "mapping_to_path - requestmapping style6" do + instance.mapping_to_path("@RequestMapping(\"/abcd\", produces=[MediaType.APPLICATION_JSON_VALUE])").should eq(["/abcd"]) + end + it "mapping_to_path - requestmapping style7" do + instance.mapping_to_path("@GetMapping").should eq([""]) + end +end + +describe "utils func" do + options = default_options() + instance = AnalyzerKotlinSpring.new(options) + + it "is_bracket - true" do + instance.is_bracket("{abcd=1234}").should eq(true) + end + it "is_bracket - false" do + instance.is_bracket("abcd=1234").should eq(false) + end + it "comma_in_bracket" do + instance.comma_in_bracket("{abcd,1234}").should eq("abcd_BRACKET_COMMA_1234") + end +end diff --git a/spec/unit_test/detector/detect_java_spring_spec.cr b/spec/unit_test/detector/detect_java_spring_spec.cr index f8d1d727..bd5ff999 100644 --- a/spec/unit_test/detector/detect_java_spring_spec.cr +++ b/spec/unit_test/detector/detect_java_spring_spec.cr @@ -4,7 +4,10 @@ describe "Detect Java Spring" do options = default_options() instance = DetectorJavaSpring.new options - it "test.java" do - instance.detect("test.java", "import org.springframework.boot.SpringApplication;").should eq(true) + it "pom.xml" do + instance.detect("pom.xml", "org.springframework").should eq(true) + end + it "build.gradle" do + instance.detect("build.gradle", "'org.springframework.boot' version '2.6.2'").should eq(true) end end diff --git a/spec/unit_test/detector/detect_kotlin_spring_spe_spec.cr b/spec/unit_test/detector/detect_kotlin_spring_spe_spec.cr index 940b5902..76746536 100644 --- a/spec/unit_test/detector/detect_kotlin_spring_spe_spec.cr +++ b/spec/unit_test/detector/detect_kotlin_spring_spe_spec.cr @@ -1,10 +1,10 @@ require "../../../src/detector/detectors/*" -describe "Detect Kotlin Spring" do +describe "Detect Java Spring" do options = default_options() instance = DetectorKotlinSpring.new options - it "test.kt" do - instance.detect("test.kt", "import org.springframework.boot.SpringApplication").should eq(true) + it "build.gradle.kts" do + instance.detect("build.gradle.kts", "'org.springframework.boot' version '2.6.2'").should eq(true) end end diff --git a/src/analyzer/analyzers/analyzer_java_spring.cr b/src/analyzer/analyzers/analyzer_java_spring.cr index aa43c9e0..c4a52c44 100644 --- a/src/analyzer/analyzers/analyzer_java_spring.cr +++ b/src/analyzer/analyzers/analyzer_java_spring.cr @@ -105,7 +105,6 @@ class AnalyzerJavaSpring < Analyzer next if path == _path if !parser_map.has_key?(_path) _parser = create_parser(Path.new(_path)) - parser_map[_path] = _parser else _parser = parser_map[_path] end @@ -356,11 +355,9 @@ class AnalyzerJavaSpring < Analyzer request_param_name = request_parameter_tokens[0].value request_param_value = request_parameter_tokens[-1].value - # Extract 'name' from @RequestParam(value/defaultValue/name = "name") + # Extract 'name' from @RequestParam(value/defaultValue = "name") if request_param_name == "value" parameter_name = request_param_value[1..-2] - elsif request_param_name == "name" - parameter_name = request_param_value[1..-2] elsif request_param_name == "defaultValue" default_value = request_param_value[1..-2] end diff --git a/src/analyzer/analyzers/analyzer_kotlin_spring.cr b/src/analyzer/analyzers/analyzer_kotlin_spring.cr index 83c01b4c..32677b0a 100644 --- a/src/analyzer/analyzers/analyzer_kotlin_spring.cr +++ b/src/analyzer/analyzers/analyzer_kotlin_spring.cr @@ -1,467 +1,247 @@ require "../../models/analyzer" -require "../../minilexers/kotlin" -require "../../miniparsers/kotlin" class AnalyzerKotlinSpring < Analyzer + REGEX_CLASS_DEFINITION = /^(((public|private|protected|default)\s+)|^)class\s+/ REGEX_ROUTER_CODE_BLOCK = /route\(\)?.*?\);/m REGEX_ROUTE_CODE_LINE = /((?:andRoute|route)\s*\(|\.)\s*(GET|POST|DELETE|PUT)\(\s*"([^"]*)/ - FILE_CONTENT_CACHE = Hash(String, String).new - KOTLIN_EXTENSION = "kt" - HTTP_METHODS = %w[GET POST PUT DELETE PATCH] def analyze - parser_map = Hash(String, KotlinParser).new - package_map = Hash(String, Hash(String, KotlinParser::ClassModel)).new - webflux_base_path_map = Hash(String, String).new - - Dir.glob("#{@base_path}/**/*") do |path| - next unless File.exists?(path) + # Source Analysis + begin + Dir.glob("#{@base_path}/**/*") do |path| + next if File.directory?(path) + + url = "" + if File.exists?(path) && path.ends_with?(".kt") + content = File.read(path, encoding: "utf-8", invalid: :skip) + last_endpoint = Endpoint.new("", "") + + # Spring MVC + has_class_been_imported = false + content.each_line.with_index do |line, index| + details = Details.new(PathInfo.new(path, index + 1)) + if has_class_been_imported == false && REGEX_CLASS_DEFINITION.match(line) + has_class_been_imported = true + end - if File.directory?(path) - process_directory(path, webflux_base_path_map) - elsif path.ends_with?(".#{KOTLIN_EXTENSION}") - process_kotlin_file(path, parser_map, package_map, webflux_base_path_map) - end - end + if line.includes? "RequestMapping" + mapping_paths = mapping_to_path(line) + if has_class_been_imported == false && mapping_paths.size > 0 + class_mapping_url = mapping_paths[0] + + if class_mapping_url.ends_with?("/*") + class_mapping_url = class_mapping_url[0..-3] + end + if class_mapping_url.ends_with?("/") + class_mapping_url = class_mapping_url[0..-2] + end + + url = "#{class_mapping_url}" + else + mapping_paths.each do |mapping_path| + if line.includes? "RequestMethod" + define_requestmapping_handlers(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"]) + else + endpoint = Endpoint.new("#{url}#{mapping_path}", "GET", details) + last_endpoint = endpoint + @result << last_endpoint + end + end + end + end - Fiber.yield - @result - end + if line.includes? "PostMapping" + mapping_paths = mapping_to_path(line) + mapping_paths.each do |mapping_path| + endpoint = Endpoint.new("#{url}#{mapping_path}", "POST", details) + last_endpoint = endpoint + @result << last_endpoint + end + end + if line.includes? "PutMapping" + mapping_paths = mapping_to_path(line) + mapping_paths.each do |mapping_path| + endpoint = Endpoint.new("#{url}#{mapping_path}", "PUT", details) + last_endpoint = endpoint + @result << last_endpoint + end + end + if line.includes? "DeleteMapping" + mapping_paths = mapping_to_path(line) + mapping_paths.each do |mapping_path| + endpoint = Endpoint.new("#{url}#{mapping_path}", "DELETE", details) + last_endpoint = endpoint + @result << last_endpoint + end + end + if line.includes? "PatchMapping" + mapping_paths = mapping_to_path(line) + mapping_paths.each do |mapping_path| + endpoint = Endpoint.new("#{url}#{mapping_path}", "PATCH", details) + last_endpoint = endpoint + @result << last_endpoint + end + end + if line.includes? "GetMapping" + mapping_paths = mapping_to_path(line) + mapping_paths.each do |mapping_path| + endpoint = Endpoint.new("#{url}#{mapping_path}", "GET", details) + last_endpoint = endpoint + @result << last_endpoint + end + end - # Process directory to extract WebFlux base path from 'application.yml' - private def process_directory(path : String, webflux_base_path_map : Hash(String, String)) - if path.ends_with?("/src") - application_yml_path = File.join(path, "main/resources/application.yml") - if File.exists?(application_yml_path) - begin - config = YAML.parse(File.read(application_yml_path)) - spring = config["spring"] - if spring - webflux = spring["webflux"] - if webflux - base_path = webflux["base-path"] - if base_path - webflux_base_path = base_path.as_s - webflux_base_path_map[path] = webflux_base_path if webflux_base_path + # Param Analysis + param = line_to_param(line) + if param.name != "" + if last_endpoint.method != "" + last_endpoint.push_param(param) end end end - rescue e - # Handle parsing errors if necessary - end - end - end - end - - # Process individual Kotlin files to analyze Spring WebFlux annotations - private def process_kotlin_file(path : String, parser_map : Hash(String, KotlinParser), package_map : Hash(String, Hash(String, KotlinParser::ClassModel)), webflux_base_path_map : Hash(String, String)) - content = fetch_file_content(path) - parser = parser_map[path]? || create_parser(Path.new(path), content) - parser_map[path] ||= parser - tokens = parser.tokens - - package_name = parser.get_package_name(tokens) - return if package_name.empty? - - root_source_directory = parser.get_root_source_directory(path, package_name) - package_directory = Path.new(path).parent - - import_map = process_imports(parser, root_source_directory, package_directory, path, parser_map) - package_class_map = package_map[package_directory.to_s]? || process_package_classes(package_directory, path, parser_map) - package_map[package_directory.to_s] ||= package_class_map - - class_map = package_class_map.merge(import_map) - parser.classes.each { |source_class| class_map[source_class.name] = source_class } - process_class_annotations(path, parser, class_map, webflux_base_path_map[path]? || "") - end - # Fetch content of a file and cache it - private def fetch_file_content(path : String) : String - FILE_CONTENT_CACHE[path] ||= File.read(path, encoding: "utf-8", invalid: :skip) - end - - # Create a Kotlin parser for a given path and content - private def create_parser(path : Path, content : String = "") : KotlinParser - content = fetch_file_content(path.to_s) if content.empty? - lexer = KotlinLexer.new - tokens = lexer.tokenize(content) - KotlinParser.new(path.to_s, tokens) - end - - # Process imports in the Kotlin file to gather class models - private def process_imports(parser : KotlinParser, root_source_directory : Path, package_directory : Path, current_path : String, parser_map : Hash(String, KotlinParser)) : Hash(String, KotlinParser::ClassModel) - import_map = Hash(String, KotlinParser::ClassModel).new - parser.import_statements.each do |import_statement| - import_path = import_statement.gsub(".", "/") - if import_path.ends_with?("/*") - process_wildcard_import(root_source_directory, import_path, current_path, parser_map, import_map) - else - process_single_import(root_source_directory, import_path, package_directory, parser_map, import_map) + # Reactive Router + content.scan(REGEX_ROUTER_CODE_BLOCK) do |route_code| + method_code = route_code[0] + method_code.scan(REGEX_ROUTE_CODE_LINE) do |match| + next if match.size != 4 + method = match[2] + endpoint = match[3].gsub(/\n/, "") + details = Details.new(PathInfo.new(path)) + @result << Endpoint.new("#{url}#{endpoint}", method, details) + end + end + end end + rescue e + logger.debug e end + Fiber.yield - import_map - end - - # Handle wildcard imports - private def process_wildcard_import(root_source_directory : Path, import_path : String, current_path : String, parser_map : Hash(String, KotlinParser), import_map : Hash(String, KotlinParser::ClassModel)) - import_directory = root_source_directory.join(import_path[0..-3]) - return unless Dir.exists?(import_directory) - - # TODO: Be aware that the import file location might differ from the actual file system path. - Dir.glob("#{import_directory}/*.#{KOTLIN_EXTENSION}") do |path| - next if path == current_path - parser = parser_map[path]? || create_parser(Path.new(path)) - parser_map[path] ||= parser - parser.classes.each { |package_class| import_map[package_class.name] = package_class } - end - end - - # Handle single imports - private def process_single_import(root_source_directory : Path, import_path : String, package_directory : Path, parser_map : Hash(String, KotlinParser), import_map : Hash(String, KotlinParser::ClassModel)) - source_path = root_source_directory.join("#{import_path}.#{KOTLIN_EXTENSION}") - return if source_path.dirname == package_directory || !File.exists?(source_path) - # TODO: Be aware that the import file location might differ from the actual file system path. - parser = parser_map[source_path.to_s]? || create_parser(source_path) - parser_map[source_path.to_s] ||= parser - parser.classes.each { |package_class| import_map[package_class.name] = package_class } + @result end - # Process all classes in the same package directory - private def process_package_classes(package_directory : Path, current_path : String, parser_map : Hash(String, KotlinParser)) : Hash(String, KotlinParser::ClassModel) - package_class_map = Hash(String, KotlinParser::ClassModel).new - Dir.glob("#{package_directory}/*.#{KOTLIN_EXTENSION}") do |path| - next if path == current_path - parser = parser_map[path]? || create_parser(Path.new(path)) - parser_map[path] ||= parser - parser.classes.each { |package_class| package_class_map[package_class.name] = package_class } + def line_to_param(line : String) : Param + if line.includes? "getParameter(\"" + param = line.split("getParameter(\"")[1].split("\"")[0] + return Param.new(param, "", "query") end - package_class_map - end - # Process class annotations to find URL mappings and HTTP methods - private def process_class_annotations(path : String, parser : KotlinParser, class_map : Hash(String, KotlinParser::ClassModel), webflux_base_path : String) - parser.classes.each do |class_model| - class_annotation = class_model.annotations["@RequestMapping"]? - - url = class_annotation ? extract_url_from_annotation(class_annotation) : "" - class_model.methods.values.each do |method| - process_method_annotations(path, parser, method, class_map, webflux_base_path, url) - end + if line.includes? "@RequestParam(\"" + param = line.split("@RequestParam(\"")[1].split("\"")[0] + return Param.new(param, "", "query") end - end - # Extract URL from class annotation - private def extract_url_from_annotation(annotation_model : KotlinParser::AnnotationModel) : String - return "" if annotation_model.params.empty? - url_token = annotation_model.params[0][-1] - url = url_token.type == :STRING_LITERAL ? url_token.value[1..-2] : "" - url.ends_with?("*") ? url[0..-2] : url + Param.new("", "", "") end - # Process method annotations to find specific mappings and create endpoints - private def process_method_annotations(path : String, parser : KotlinParser, method : KotlinParser::MethodModel, class_map : Hash(String, KotlinParser::ClassModel), webflux_base_path : String, url : String) - method.annotations.values.each do |method_annotation| - next unless method_annotation.name.ends_with?("Mapping") - - request_optional, parameter_format = extract_request_methods_and_format(parser, method_annotation) - url_paths = method_annotation.name.starts_with?("@") ? extract_mapping_paths(parser, method_annotation) : [""] - details = Details.new(PathInfo.new(path, method_annotation.tokens[0].line)) - url_paths += request_optional["values"] - url_paths += request_optional["paths"] - - create_endpoints(webflux_base_path, url, url_paths, request_optional, parser, method, parameter_format, class_map, details) + def mapping_to_path(line : String) + unless line.includes? "(" + # no path + return [""] end - end - - # Extract HTTP methods and parameter format from annotation - private def extract_request_methods_and_format(parser : KotlinParser, annotation_model : KotlinParser::AnnotationModel) : Tuple(Hash(String, Array(String)), String | Nil) - parameter_format = nil - request_optional = Hash(String, Array(String)).new - request_optional["methods"] = Array(String).new - request_optional["params"] = Array(String).new - request_optional["headers"] = Array(String).new - request_optional["values"] = Array(String).new - request_optional["paths"] = Array(String).new - annotation_model.params.each do |tokens| - next if tokens.size < 3 - next if tokens[2].value != "[" && tokens[2].value != "arrayOf" - bracket_index = tokens[2].value != "arrayOf" ? tokens[2].index : tokens[2].index + 1 - - case tokens[0].value - when "method" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - method_index = param_tokens[0].value != "RequestMethod" ? 0 : 2 - request_optional["methods"] << param_tokens[method_index].value - end - when "consumes" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - parameter_format = case param_tokens[0].value[1..-2].upcase - when "APPLICATION/X-WWW-FORM-URLENCODED" - "form" - when "APPLICATION/JSON" - "json" - else - nil - end - break + paths = Array(String).new + splited_line = line.strip.split("(") + if splited_line.size > 1 && splited_line[1].includes? ")" + params = splited_line[1].split(")")[0] + params = params.gsub(/\s/, "") # remove space + if params.size > 0 + path = nil + # value parameter + if params.includes? "value=" + value = params.split("value=")[1] + if value.size > 0 + if value[0] == '"' + path = value.split("\"")[1] + elsif value[0] == '{' && value.includes? "}" + path = value[1..].split("}")[0] end end end - when "params" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - request_optional["params"] << param_tokens[0].value[1..-2] - end - end - when "headers" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - request_optional["headers"] << param_tokens[0].value[1..-2] - end - end - when "value" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - request_optional["values"] << param_tokens[0].value[1..-2] - end - end - when "path" - parser.parse_formal_parameters(bracket_index).each do |param_tokens| - if param_tokens.size > 0 && param_tokens[0].type == :STRING_LITERAL - request_optional["paths"] << param_tokens[0].value[1..-2] - end - end - end - end - - if request_optional["methods"].empty? - if annotation_model.name == "@RequestMapping" - # Default to all HTTP methods if no method is specified - request_optional["methods"].concat(HTTP_METHODS) - else - # Extract HTTP method from annotation name - http_method = HTTP_METHODS.find { |method| annotation_model.name.upcase == "@#{method}MAPPING" } - request_optional["methods"].push(http_method) if http_method - end - end - - {request_optional, parameter_format} - end - - # Extract URL mapping paths from annotation parameters - private def extract_mapping_paths(parser : KotlinParser, annotation_model : KotlinParser::AnnotationModel) : Array(String) - return [""] if annotation_model.params.empty? - get_mapping_path(parser, annotation_model.params) - end - # Create endpoints for the extracted HTTP methods and paths - private def create_endpoints(webflux_base_path : String, url : String, url_paths : Array(String), request_optional : Hash(String, Array(String)), parser : KotlinParser, method : KotlinParser::MethodModel, parameter_format : String | Nil, class_map : Hash(String, KotlinParser::ClassModel), details : Details) - webflux_base_path = webflux_base_path.chomp("/") if webflux_base_path.ends_with?("/") - url_paths.each do |url_path| - full_url = "#{webflux_base_path}#{url}#{url_path}" - request_optional["methods"].each do |request_method| - parameter_format = case request_method - when "POST", "PUT", "DELETE" - "form" if parameter_format.nil? - when "GET" - "query" if parameter_format.nil? - end - parameters = get_endpoint_parameters(parser, method, parameter_format, class_map) - request_optional["params"].each do |param| - parameter_format = "query" if parameter_format.nil? - param, default_value = if param.includes?("=") - param.split("=") - else - [param, ""] - end - new_param_obj = Param.new(param, default_value, parameter_format) - if parameters.find { |param_obj| param_obj == new_param_obj }.nil? - parameters << new_param_obj + # first parameter + if path.nil? + if params[0] == '"' + path = params.split("\"")[1] + elsif params[0] == '{' && params.includes? "}" + path = params[1..].split("}")[0] end end - request_optional["headers"].each do |header| - parameter_format = "header" - param, default_value = if header.includes?("=") - header.split("=") - else - [header, ""] - end - new_param_obj = Param.new(param, default_value, parameter_format) - if parameters.find { |param_obj| param_obj == new_param_obj }.nil? - parameters << new_param_obj - end - end - @result << Endpoint.new(full_url, request_method, parameters, details) - end - end - end - - # Extract mapping paths from annotation parameters - private def get_mapping_path(parser : KotlinParser, method_params : Array(Array(Token))) : Array(String) - url_paths = Array(String).new - path_argument_index = method_params.index { |param| param[0].value == "value" } || 0 - path_parameter_tokens = method_params[path_argument_index] - if path_parameter_tokens[-1].type == :STRING_LITERAL - url_paths << path_parameter_tokens[-1].value[1..-2] - elsif path_parameter_tokens[-1].type == :RBRACE - i = path_parameter_tokens.size - 2 - while i > 0 - parameter_token = path_parameter_tokens[i] - case parameter_token.type - when :LCURL - break - when :COMMA - i -= 1 - next - when :STRING_LITERAL - url_paths << parameter_token.value[1..-2] + # extract path + if path.nil? + # can't find path + paths << "" else - break - end - i -= 1 - end - end - - url_paths - end - - # Get endpoint parameters from the method's annotation and signature - private def get_endpoint_parameters(parser : KotlinParser, method : KotlinParser::MethodModel, parameter_format : String | Nil, package_class_map : Hash(String, KotlinParser::ClassModel)) : Array(Param) - endpoint_parameters = Array(Param).new - method.params.each do |tokens| - next if tokens.size < 3 - - i = 0 - while i < tokens.size - case tokens[i + 1].type - when :ANNOTATION - i += 1 - when :LPAREN - rparen = parser.find_bracket_partner(tokens[i + 1].index) - if rparen && tokens[i + (rparen - tokens[i + 1].index) + 2].type == :ANNOTATION - i += rparen - tokens[i + 1].index + 2 + if path.size > 0 && path[0] == '"' && path.includes? "," + # multiple path + path.split(",").each do |each_path| + if each_path.size > 0 + if each_path[0] == '"' + paths << each_path[1..-2] + else + paths << "" + end + end + end else - break + # single path + if path.size > 0 && path[0] == '"' + path = path.split("\"")[1] + end + + paths << path end - else - break end - end - - token = tokens[i] - parameter_index = tokens[-1].value != "?" ? -1 : -2 - if tokens[parameter_index].value == "Pageable" - next if parameter_format.nil? - endpoint_parameters << Param.new("page", "", parameter_format) - endpoint_parameters << Param.new("size", "", parameter_format) - endpoint_parameters << Param.new("sort", "", parameter_format) else - name = token.value - parameter_format = get_parameter_format(name, parameter_format) - next if parameter_format.nil? - - default_value, parameter_name, parameter_type = extract_parameter_details(tokens, parser, i) - next if parameter_name.empty? || parameter_type.nil? - - param_default_value = default_value.nil? ? "" : default_value - if ["long", "int", "integer", "char", "boolean", "string", "multipartfile"].includes?(parameter_type.downcase) - endpoint_parameters << Param.new(parameter_name, param_default_value, parameter_format) - else - add_user_defined_class_params(package_class_map, parameter_type, default_value, parameter_name, parameter_format, endpoint_parameters) - end + # no path + paths << "" end end - endpoint_parameters - end - # Get parameter format based on annotation name - private def get_parameter_format(name : String, current_format : String | Nil) : String | Nil - case name - when "@RequestBody" - current_format || "json" - when "@RequestParam" - "query" - when "@RequestHeader" - "header" - when "@CookieValue" - "cookie" - when "@PathVariable" - nil - when "@ModelAttribute" - current_format || "form" - else - current_format || "query" + # append slash + (0..paths.size - 1).each do |i| + path = paths[i] + if path.size > 0 && !path.starts_with? "/" + path = "/" + path + end + + paths[i] = path end - end - # Extract details of parameters from tokens - private def extract_parameter_details(tokens : Array(Token), parser : KotlinParser, index : Int32) : Tuple(String, String, String?) - default_value = "" - parameter_name = "" - parameter_type = nil + paths + end - if tokens[index + 1].type == :LPAREN - attributes = parser.parse_formal_parameters(tokens[index + 1].index) - attributes.each do |attribute_tokens| - if attribute_tokens.size > 2 - attribute_name = attribute_tokens[0].value - attribute_value = attribute_tokens[2].value - case attribute_name - when "value", "name" - parameter_name = attribute_value - when "defaultValue" - default_value = attribute_value - end - else - parameter_name = attribute_tokens[0].value - end - end - end + def is_bracket(content : String) + content.gsub(/\s/, "")[0].to_s == "{" + end - colon_index = tokens[-1].value == "?" ? -3 : -2 - if tokens[colon_index].type == :COLON - parameter_name = tokens[-3].value if parameter_name.empty? && tokens[-3].type == :IDENTIFIER - parameter_type = tokens[-1].type == :QUEST ? tokens[-2].value : tokens[-1].value if tokens[-1].type == :IDENTIFIER - elsif tokens[colon_index + 1].type == :RANGLE - parameter_type = tokens[-2].value - parameter_name = tokens[-6].value if tokens[-5].type == :COLON + def comma_in_bracket(content : String) + result = content.gsub(/\{(.*?)\}/) do |match| + match.gsub(",", "_BRACKET_COMMA_") end - default_value = default_value[1..-2] if default_value.size > 1 && default_value[0] == '"' && default_value[-1] == '"' - parameter_name = parameter_name[1..-2] if parameter_name.size > 1 && parameter_name[0] == '"' && parameter_name[-1] == '"' + result.gsub("{", "").gsub("}", "") + end - {default_value, parameter_name, parameter_type} + def extract_param(content : String) + # TODO + # case1 -> @RequestParam("a") + # case2 -> String a = param.get("a"); + # case3 -> String a = request.getParameter("a"); + # case4 -> (PATH) @PathVariable("a") end - # Add parameters from user-defined class fields - private def add_user_defined_class_params(package_class_map : Hash(String, KotlinParser::ClassModel), parameter_type : String, default_value : String?, parameter_name : String, parameter_format : String | Nil, endpoint_parameters : Array(Param)) - if package_class_map.has_key?(parameter_type) - package_class = package_class_map[parameter_type] - if package_class.enum_class? - param_default_value = default_value.nil? ? "" : default_value - endpoint_parameters << Param.new(parameter_name, param_default_value, parameter_format) - else - package_class.fields.values.each do |field| - if package_class_map.has_key?(field.type) && parameter_type != field.type - add_user_defined_class_params(package_class_map, field.type, field.init_value, field.name, parameter_format, endpoint_parameters) - else - if field.access_modifier == "public" || field.has_setter? - param_default_value = default_value.nil? ? field.init_value : default_value - endpoint_parameters << Param.new(field.name, param_default_value, parameter_format) - end - end - end + macro define_requestmapping_handlers(methods) + {% for method, index in methods %} + if line.includes? "RequestMethod.{{method.id}}" + @result << Endpoint.new("#{url}#{mapping_path}", "{{method.id}}") end - end + {% end %} end end -# Function to instantiate and run the AnalyzerKotlinSpring def analyzer_kotlin_spring(options : Hash(Symbol, String)) instance = AnalyzerKotlinSpring.new(options) instance.analyze diff --git a/src/detector/detectors/java_spring.cr b/src/detector/detectors/java_spring.cr index f5466e5b..49113004 100644 --- a/src/detector/detectors/java_spring.cr +++ b/src/detector/detectors/java_spring.cr @@ -2,7 +2,9 @@ require "../../models/detector" class DetectorJavaSpring < Detector def detect(filename : String, file_contents : String) : Bool - if (filename.ends_with? ".java") && (file_contents.includes? "org.springframework") + if (filename.ends_with? "build.gradle") && (file_contents.includes? "org.springframework") + return true + elsif (filename.ends_with? "pom.xml") && (file_contents.includes? "org.springframework") return true end diff --git a/src/detector/detectors/kotlin_spring.cr b/src/detector/detectors/kotlin_spring.cr index 9fa3ad41..52f32abe 100644 --- a/src/detector/detectors/kotlin_spring.cr +++ b/src/detector/detectors/kotlin_spring.cr @@ -2,7 +2,9 @@ require "../../models/detector" class DetectorKotlinSpring < Detector def detect(filename : String, file_contents : String) : Bool - if (filename.ends_with? ".kt") && (file_contents.includes? "org.springframework") + if (filename.ends_with? "build.gradle.kts") && (file_contents.includes? "org.springframework") + return true + elsif (filename.ends_with? "pom.xml") && (file_contents.includes? "org.springframework") && (file_contents.includes? "org.jetbrains.kotlin") return true end diff --git a/src/minilexers/java.cr b/src/minilexers/java.cr index fdae1a15..de46085f 100644 --- a/src/minilexers/java.cr +++ b/src/minilexers/java.cr @@ -1,170 +1,170 @@ require "../models/minilexer/*" -class JavaLexer < MiniLexer - # Keywords - ABSTRACT = "abstract" - ASSERT = "assert" - BOOLEAN = "boolean" - BREAK = "break" - BYTE = "byte" - CASE = "case" - CATCH = "catch" - CHAR = "char" - CLASS = "class" - CONST = "const" - CONTINUE = "continue" - DEFAULT = "default" - DO = "do" - DOUBLE = "double" - ELSE = "else" - ENUM = "enum" - EXTENDS = "extends" - FINAL = "final" - FINALLY = "finally" - FLOAT = "float" - FOR = "for" - IF = "if" - GOTO = "goto" - IMPLEMENTS = "implements" - IMPORT = "import" - INSTANCEOF = "instanceof" - INT = "int" - INTERFACE = "interface" - LONG = "long" - NATIVE = "native" - NEW = "new" - PACKAGE = "package" - PRIVATE = "private" - PROTECTED = "protected" - PUBLIC = "public" - RETURN = "return" - SHORT = "short" - STATIC = "static" - STRICTFP = "strictfp" - SUPER = "super" - SWITCH = "switch" - SYNCHRONIZED = "synchronized" - THIS = "this" - THROW = "throw" - THROWS = "throws" - TRANSIENT = "transient" - TRY = "try" - VOID = "void" - VOLATILE = "volatile" - WHILE = "while" +# Keywords +ABSTRACT = "abstract" +ASSERT = "assert" +BOOLEAN = "boolean" +BREAK = "break" +BYTE = "byte" +CASE = "case" +CATCH = "catch" +CHAR = "char" +CLASS = "class" +CONST = "const" +CONTINUE = "continue" +DEFAULT = "default" +DO = "do" +DOUBLE = "double" +ELSE = "else" +ENUM = "enum" +EXTENDS = "extends" +FINAL = "final" +FINALLY = "finally" +FLOAT = "float" +FOR = "for" +IF = "if" +GOTO = "goto" +IMPLEMENTS = "implements" +IMPORT = "import" +INSTANCEOF = "instanceof" +INT = "int" +INTERFACE = "interface" +LONG = "long" +NATIVE = "native" +NEW = "new" +PACKAGE = "package" +PRIVATE = "private" +PROTECTED = "protected" +PUBLIC = "public" +RETURN = "return" +SHORT = "short" +STATIC = "static" +STRICTFP = "strictfp" +SUPER = "super" +SWITCH = "switch" +SYNCHRONIZED = "synchronized" +THIS = "this" +THROW = "throw" +THROWS = "throws" +TRANSIENT = "transient" +TRY = "try" +VOID = "void" +VOLATILE = "volatile" +WHILE = "while" - # Module related keywords - MODULE = "module" - OPEN = "open" - REQUIRES = "requires" - EXPORTS = "exports" - OPENS = "opens" - TO = "to" - USES = "uses" - PROVIDES = "provides" - WITH = "with" - TRANSITIVE = "transitive" +# Module related keywords +MODULE = "module" +OPEN = "open" +REQUIRES = "requires" +EXPORTS = "exports" +OPENS = "opens" +TO = "to" +USES = "uses" +PROVIDES = "provides" +WITH = "with" +TRANSITIVE = "transitive" - # Local Variable Type Inference - VAR = "var" # reserved type name +# Local Variable Type Inference +VAR = "var" # reserved type name - # Switch Expressions - YIELD = "yield" # reserved type name from Java 14 +# Switch Expressions +YIELD = "yield" # reserved type name from Java 14 - # Records - RECORD = "record" +# Records +RECORD = "record" - # Sealed Classes - SEALED = "sealed" - PERMITS = "permits" - NON_SEALED = "non-sealed" +# Sealed Classes +SEALED = "sealed" +PERMITS = "permits" +NON_SEALED = "non-sealed" - # Literals - DECIMAL_LITERAL = /0|[1-9]([_\d]*\d)?[lL]?/ - HEX_LITERAL = /0[xX][0-9a-fA-F]([0-9a-fA-F_]*[0-9a-fA-F])?[lL]?/ - OCT_LITERAL = /0[0-7]([0-7_]*[0-7])?[lL]?/ - BINARY_LITERAL = /0[bB][01]([01_]*[01])?[lL]?/ - FLOAT_LITERAL = /((\d+\.\d*|\.\d+)([eE][+-]?\d+)?|[+-]?\d+[eE][+-]?\d+)[fFdD]?/ - HEX_FLOAT_LITERAL = /0[xX]([0-9a-fA-F]+(\.[0-9a-fA-F]*)?|\.[0-9a-fA-F]+)[pP][+-]?\d+[fFdD]?/ - BOOL_LITERAL = /true|false/ - CHAR_LITERAL = /'([^'\\\r\n]|\\['"\\bfnrt]|\\u[0-9a-fA-F]{4}|\\[^'"\r\n])*'/ - STRING_LITERAL = /"([^"\\\r\n]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4}|\\[^"\r\n])*"/ - TEXT_BLOCK = /"""\s*(.|\\["\\bfnrt])*?\s*"""/ - NULL_LITERAL = "null" +# Literals +DECIMAL_LITERAL = /0|[1-9]([_\d]*\d)?[lL]?/ +HEX_LITERAL = /0[xX][0-9a-fA-F]([0-9a-fA-F_]*[0-9a-fA-F])?[lL]?/ +OCT_LITERAL = /0[0-7]([0-7_]*[0-7])?[lL]?/ +BINARY_LITERAL = /0[bB][01]([01_]*[01])?[lL]?/ +FLOAT_LITERAL = /((\d+\.\d*|\.\d+)([eE][+-]?\d+)?|[+-]?\d+[eE][+-]?\d+)[fFdD]?/ +HEX_FLOAT_LITERAL = /0[xX]([0-9a-fA-F]+(\.[0-9a-fA-F]*)?|\.[0-9a-fA-F]+)[pP][+-]?\d+[fFdD]?/ +BOOL_LITERAL = /true|false/ +CHAR_LITERAL = /'([^'\\\r\n]|\\['"\\bfnrt]|\\u[0-9a-fA-F]{4}|\\[^'"\r\n])*'/ +STRING_LITERAL = /"([^"\\\r\n]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4}|\\[^"\r\n])*"/ +TEXT_BLOCK = /"""\s*(.|\\["\\bfnrt])*?\s*"""/ +NULL_LITERAL = "null" - # Separators - LPAREN = "(" - RPAREN = ")" - LBRACE = "{" - RBRACE = "}" - LBRACK = "[" - RBRACK = "]" - SEMI = ";" - COMMA = "," - DOT = "." +# Separators +LPAREN = "(" +RPAREN = ")" +LBRACE = "{" +RBRACE = "}" +LBRACK = "[" +RBRACK = "]" +SEMI = ";" +COMMA = "," +DOT = "." - # Operators - ASSIGN = "=" - GT = ">" - LT = "<" - BANG = "!" - TILDE = "~" - QUESTION = "?" - COLON = ":" - EQUAL = "==" - LE = "<=" - GE = ">=" - NOTEQUAL = "!=" - AND = "&&" - OR = "||" - INC = "++" - DEC = "--" - ADD = "+" - SUB = "-" - MUL = "*" - DIV = "/" - BITAND = "&" - BITOR = "|" - CARET = "^" - MOD = "%" +# Operators +ASSIGN = "=" +GT = ">" +LT = "<" +BANG = "!" +TILDE = "~" +QUESTION = "?" +COLON = ":" +EQUAL = "==" +LE = "<=" +GE = ">=" +NOTEQUAL = "!=" +AND = "&&" +OR = "||" +INC = "++" +DEC = "--" +ADD = "+" +SUB = "-" +MUL = "*" +DIV = "/" +BITAND = "&" +BITOR = "|" +CARET = "^" +MOD = "%" - ADD_ASSIGN = "+=" - SUB_ASSIGN = "-=" - MUL_ASSIGN = "*=" - DIV_ASSIGN = "/=" - AND_ASSIGN = "&=" - OR_ASSIGN = "|=" - XOR_ASSIGN = "^=" - MOD_ASSIGN = "%=" - LSHIFT_ASSIGN = "<<=" - RSHIFT_ASSIGN = ">>=" - URSHIFT_ASSIGN = ">>>=" +ADD_ASSIGN = "+=" +SUB_ASSIGN = "-=" +MUL_ASSIGN = "*=" +DIV_ASSIGN = "/=" +AND_ASSIGN = "&=" +OR_ASSIGN = "|=" +XOR_ASSIGN = "^=" +MOD_ASSIGN = "%=" +LSHIFT_ASSIGN = "<<=" +RSHIFT_ASSIGN = ">>=" +URSHIFT_ASSIGN = ">>>=" - # Java 8 tokens - ARROW = "->" - COLONCOLON = "::" +# Java 8 tokens +ARROW = "->" +COLONCOLON = "::" - # Additional symbols not defined in the lexical specification - AT = "@" - ELLIPSIS = "..." +# Additional symbols not defined in the lexical specification +AT = "@" +ELLIPSIS = "..." - # Whitespace and comments - WS = /[ \t\r\n\x0C]+/ - COMMENT = /\/\*.*?\*\//m - LINE_COMMENT = /\/\/[^\r\n]*/ +# Whitespace and comments +WS = /[ \t\r\n\x0C]+/ +COMMENT = /\/\*.*?\*\//m +LINE_COMMENT = /\/\/[^\r\n]*/ - # Identifiers - IDENTIFIER = /[a-zA-Z$_][a-zA-Z\d$_]*/ +# Identifiers +IDENTIFIER = /[a-zA-Z$_][a-zA-Z\d$_]*/ - # Fragment rules - ExponentPart = /[eE][+-]?\d+/ - EscapeSequence = /\\(?:u005c)?[btnfr"'\\]|\\u(?:[0-3]?[0-7])?[0-7]|\\u[0-9a-fA-F]{4}/ - HexDigits = /[0-9a-fA-F]([_0-9a-fA-F]*[0-9a-fA-F])?/ - HexDigit = /[0-9a-fA-F]/ - Digits = /\d([_\d]*\d)?/ - LetterOrDigit = /[a-zA-Z\d$_]/ - Letter = /[a-zA-Z$_]|[^[:ascii:]]/ +# Fragment rules +ExponentPart = /[eE][+-]?\d+/ +EscapeSequence = /\\(?:u005c)?[btnfr"'\\]|\\u(?:[0-3]?[0-7])?[0-7]|\\u[0-9a-fA-F]{4}/ +HexDigits = /[0-9a-fA-F]([_0-9a-fA-F]*[0-9a-fA-F])?/ +HexDigit = /[0-9a-fA-F]/ +Digits = /\d([_\d]*\d)?/ +LetterOrDigit = /[a-zA-Z\d$_]/ +Letter = /[a-zA-Z$_]|[^[:ascii:]]/ +class JavaLexer < MiniLexer def initialize super end diff --git a/src/minilexers/kotlin.cr b/src/minilexers/kotlin.cr deleted file mode 100644 index eba90b81..00000000 --- a/src/minilexers/kotlin.cr +++ /dev/null @@ -1,245 +0,0 @@ -require "../models/minilexer/*" - -class KotlinLexer < MiniLexer - KEYWORDS = { - "file" => :FILE, "package" => :PACKAGE, "import" => :IMPORT, "class" => :CLASS, - "interface" => :INTERFACE, "fun" => :FUN, "object" => :OBJECT, "val" => :VAL, - "var" => :VAR, "typealias" => :TYPE_ALIAS, "constructor" => :CONSTRUCTOR, "by" => :BY, - "companion" => :COMPANION, "init" => :INIT, "this" => :THIS, "super" => :SUPER, - "typeof" => :TYPEOF, "where" => :WHERE, "if" => :IF, "else" => :ELSE, "when" => :WHEN, - "try" => :TRY, "catch" => :CATCH, "finally" => :FINALLY, "for" => :FOR, "do" => :DO, - "while" => :WHILE, "throw" => :THROW, "return" => :RETURN, "continue" => :CONTINUE, - "break" => :BREAK, "as" => :AS, "is" => :IS, "in" => :IN, "out" => :OUT, - "public" => :PUBLIC, "private" => :PRIVATE, "protected" => :PROTECTED, "internal" => :INTERNAL, - "enum" => :ENUM, "sealed" => :SEALED, "annotation" => :ANNOTATION, "data" => :DATA, - "inner" => :INNER, "tailrec" => :TAILREC, "operator" => :OPERATOR, "inline" => :INLINE, - "infix" => :INFIX, "external" => :EXTERNAL, "suspend" => :SUSPEND, "override" => :OVERRIDE, - "abstract" => :ABSTRACT, "final" => :FINAL, "open" => :OPEN, "const" => :CONST, - "lateinit" => :LATEINIT, "vararg" => :VARARG, "noinline" => :NOINLINE, - "crossinline" => :CROSSINLINE, "reified" => :REIFIED, - } - - ANNOTATIONS = { - "@field" => :FIELD, - "@property" => :PROPERTY, - "@get" => :GET, - "@set" => :SET, - "@receiver" => :RECEIVER, - "@param" => :PARAM, - "@setparam" => :SETPARAM, - "@delegate" => :DELEGATE, - } - - PUNCTUATION = { - '.' => :DOT, ',' => :COMMA, '(' => :LPAREN, ')' => :RPAREN, - '{' => :LCURL, '}' => :RCURL, '[' => :LSQUARE, ']' => :RSQUARE, - ';' => :SEMI, ':' => :COLON, '?' => :QUESTION, - } - - OPERATORS = { - '+' => :ADD, '-' => :SUB, '*' => :MULT, '/' => :DIV, '%' => :MOD, - '=' => :ASSIGN, "==" => :EQUAL, "!=" => :NOTEQUAL, '>' => :RANGLE, '<' => :LANGLE, - ">=" => :GE, "<=" => :LE, "&&" => :AND, "||" => :OR, '!' => :BANG, - "++" => :INC, "--" => :DEC, "+=" => :ADD_ASSIGN, "-=" => :SUB_ASSIGN, - "*=" => :MUL_ASSIGN, "/=" => :DIV_ASSIGN, "%=" => :MOD_ASSIGN, - '&' => :BITAND, '|' => :BITOR, '^' => :CARET, '~' => :TILDE, - "->" => :ARROW, "=>" => :DOUBLE_ARROW, "?:" => :ELVIS, - } - - def initialize - super - end - - def tokenize(@input : String) : Array(Token) - super - end - - def tokenize_logic(@input : String) : Array(Token) - after_skip = -1 - while @position < @input.size - while @position != after_skip - skip_whitespace_and_comments - after_skip = @position - end - break if @position == @input.size - - case @input[@position] - when '0'..'9' - match_number - when 'a'..'z', 'A'..'Z', '_' - match_identifier_or_keyword - when '@' - match_annotation - when '"', '\'' - match_string_or_char_literal - when '.', ',', '(', ')', '{', '}', '[', ']', ';', '?', ':' - match_punctuation - when '+', '-', '*', '/', '%', '&', '|', '^', '!', '=', '<', '>', '~' - match_operator - else - match_other - end - end - - @tokens - end - - private def skip_whitespace_and_comments - while @position < @input.size - case @input[@position] - when ' ', '\t', '\r' - @position += 1 - when '/' - if @position + 1 < @input.size - case @input[@position + 1] - when '/' - # Skip single line comment - @position += 2 - while @position < @input.size && @input[@position] != '\n' - @position += 1 - end - when '*' - # Skip multi-line comment - @position += 2 - while @position + 1 < @input.size - if @input[@position] == '*' && @input[@position + 1] == '/' - @position += 2 - break - end - @position += 1 - end - else - break - end - else - break - end - else - break - end - end - end - - private def match_number - if match = @input.match(/\d+(_\d+)*\.?\d*([eE][+-]?\d+)?/, @position) - type = match[0].includes?('.') ? :FLOAT_LITERAL : :INTEGER_LITERAL - self << Tuple.new(type, match[0]) - @position += match[0].size - else - self << Tuple.new(:UNKNOWN, @input[@position]) - @position += 1 - end - end - - private def match_identifier_or_keyword - if match = @input.match(/[a-zA-Z_][a-zA-Z0-9_]*/, @position) - type = KEYWORDS[match[0]]? || :IDENTIFIER - self << Tuple.new(type, match[0]) - @position += match[0].size - else - self << Tuple.new(:UNKNOWN, @input[@position]) - @position += 1 - end - end - - private def match_annotation - if match = @input.match(/\@[a-zA-Z_][a-zA-Z0-9_]*/, @position) - type = KotlinLexer::ANNOTATIONS[match[0]]? || :ANNOTATION - self << Tuple.new(type, match[0]) - @position += match[0].size - else - self << Tuple.new(:UNKNOWN, @input[@position]) - @position += 1 - end - end - - private def match_string_or_char_literal - s = @input[@position].to_s - - text_block_literal = "\"\"\"" - if @position < @input.size - 3 && @input[@position..@position + 2] == text_block_literal - s = text_block_literal - @position += 3 - while @position < @input.size - 3 - s += @input[@position] - if @input[@position..@position + 2] == text_block_literal - s += "\"\"" - break - end - @position += 1 - end - - if s.starts_with?(text_block_literal) && s.ends_with?(text_block_literal) - self << Tuple.new(:TEXT_BLOCK, s) - @position += 3 - return - end - else - @position += 1 - is_backslash_char = false - while @position < @input.size - s += @input[@position] - break if !is_backslash_char && @input[@position] == s[0] - is_backslash_char = s[0] == '\\' - @position += 1 - end - - if s.size > 1 - if s[0] == '\'' && s[-1] == '\'' - self << Tuple.new(:CHAR_LITERAL, s) - @position += 1 - return - elsif s[0] == '"' && s[-1] == '"' - self << Tuple.new(:STRING_LITERAL, s) - @position += 1 - return - end - end - end - end - - private def match_punctuation - char = @input[@position] - type = PUNCTUATION[char]? || :UNKNOWN - self << Tuple.new(type, char) - @position += 1 - end - - private def match_operator - # Match longer operators first by checking next characters - if @position + 1 < @input.size && OPERATORS.has_key?(@input[@position..@position + 1]) - op = @input[@position..@position + 1] - @position += 2 - elsif OPERATORS.has_key?(@input[@position]) - op = @input[@position] - @position += 1 - else - op = nil - end - - if op - type = OPERATORS[op] - self << Tuple.new(type, op) - else - # Handle the case where the operator is unknown - self << Tuple.new(:UNKNOWN, @input[@position]) - @position += 1 - end - end - - def match_other - case @input[@position] - when '\t' - self << Tuple.new(:TAB, "\t") - when '\n' - self << Tuple.new(:NEWLINE, "\n") - when '@' - self << Tuple.new(:AT, '@') - when ' ' - # Skipping whitespace for efficiency - else - self << Tuple.new(:UNKNOWN, @input[@position].to_s) - end - @position += 1 - end -end diff --git a/src/miniparsers/kotlin.cr b/src/miniparsers/kotlin.cr deleted file mode 100644 index 702628b8..00000000 --- a/src/miniparsers/kotlin.cr +++ /dev/null @@ -1,758 +0,0 @@ -require "../minilexers/kotlin" -require "../models/minilexer/token" - -# Define bracket pairs for matching brackets -BRACKET_PAIRS = { - :LPAREN => :RPAREN, - :LSQUARE => :RSQUARE, - :LANGLE => :RANGLE, - :LCURL => :RCURL, - :RPAREN => :LPAREN, - :RSQUARE => :LSQUARE, - :RANGLE => :LANGLE, - :RCURL => :LCURL, -} - -class KotlinParser - property classes_tokens : Array(Array(Token)) - property classes : Array(ClassModel) - property tokens : Array(Token) - property import_statements : Array(String) - property path : String - - # Initialize the parser with the path and tokens - def initialize(@path : String, @tokens : Array(Token)) - @import_statements = Array(String).new - @classes_tokens = Array(Array(Token)).new - @classes = Array(ClassModel).new - parse() - end - - # Main parse method to handle import statements and classes - def parse - parse_import_statements(@tokens) - parse_classes(@tokens) - @classes_tokens.each do |class_tokens| - name_token = get_class_name(class_tokens) - next if name_token.nil? - modifiers = parse_class_modifiers(name_token.index) - methods = parse_methods(class_tokens) - annotations = parse_annotations_backwards(class_tokens[0].index) - fields = parse_class_parameters(name_token.index) - parse_fields_from_class_body(name_token.index, fields, methods) - @classes << ClassModel.new(modifiers, annotations, name_token.value, fields, methods, class_tokens) - end - end - - # Method to determine the root source directory based on the package name - def get_root_source_directory(path : String, package_name : String) - i = 0 - path = Path.new(path).parent - package_depth = package_name.split(".").size - while i < package_depth - path = path.parent - i += 1 - end - path - end - - # Method to extract the package name from the tokens - def get_package_name(tokens : Array(Token)) - package_start = false - tokens.each_with_index do |token, index| - if token.type == :PACKAGE - package_start = true - i = index + 1 - package_name = "" - while i < tokens.size - if tokens[i].type != :NEWLINE - if tokens[i].type == :IDENTIFIER || tokens[i].type == :DOT - package_name += tokens[i].value - end - else - return package_name - end - i += 1 - end - break - end - end - "" - end - - # Parse import statements from the tokens - def parse_import_statements(tokens : Array(Token)) - import_tokens = tokens.select { |token| token.type == :IMPORT } - import_tokens.each do |import_token| - next_token_index = import_token.index + 1 - next_token = tokens[next_token_index] - - if next_token - if next_token.type == :STATIC - next_token_index += 1 - next_token = tokens[next_token_index] - end - if next_token.type == :IDENTIFIER - import_statement = next_token.value - next_token_index += 1 - - while next_token_index < tokens.size && tokens[next_token_index].type == :DOT - next_token_index += 1 - identifier_token = tokens[next_token_index] - break if !identifier_token - break if identifier_token.type != :IDENTIFIER && identifier_token.value != "*" - import_statement += ".#{identifier_token.value}" - next_token_index += 1 - end - - @import_statements << import_statement - end - end - end - end - - # Find the matching bracket for a given token index - def find_bracket_partner(param_start_index : Int32) - token = @tokens[param_start_index] - next_direction = true - open_bracket = token.type - close_bracket = BRACKET_PAIRS[open_bracket]? - return nil if close_bracket.nil? - - # Determine the direction of search based on the type of bracket - if [:RPAREN, :RCURL, :RSQUARE, :RANGLE].includes?(token.type) - next_direction = false - open_bracket = BRACKET_PAIRS[token.type]? - close_bracket = token.type - end - - # Search for the matching bracket - if next_direction - nesting = 1 - index = param_start_index + 1 - while index < @tokens.size - token = @tokens[index] - if token.type == open_bracket - nesting += 1 - elsif token.type == close_bracket - nesting -= 1 - return index if nesting == 0 - end - index += 1 - end - else - nesting = -1 - index = param_start_index - 1 - while index >= 0 - token = @tokens[index] - if token.type == open_bracket - nesting += 1 - return index if nesting == 0 - elsif token.type == close_bracket - nesting -= 1 - end - index -= 1 - end - end - end - - # Parse the formal parameters of a method or constructor - def parse_formal_parameters(param_start_index : Int32) - parameters = Array(Array(Token)).new - partner_index = find_bracket_partner(param_start_index) - return parameters if partner_index.nil? - - parameter = Array(Token).new - start_index = param_start_index + 1 - end_index = partner_index - 1 - start_index, end_index = end_index, start_index if start_index > end_index - - while start_index <= end_index - token = @tokens[start_index] - if token.type == :LPAREN || token.type == :LANGLE || token.type == :LSQUARE - partner_index = find_bracket_partner(start_index) - if partner_index - (start_index..partner_index).each { |i| parameter << @tokens[i] } - start_index = partner_index - end - elsif token.type == :COMMA - parameters << parameter - parameter = Array(Token).new - elsif token.type != :TAB && token.type != :NEWLINE - parameter << token - end - start_index += 1 - end - - parameters << parameter if parameter.size > 0 - parameters - end - - # Find the end of an annotation - def find_annotation_end(start_index) - return nil if @tokens[start_index].type != :ANNOTATION - - annotation_token = @tokens[start_index] - annotation_name = annotation_token.value - if ["@field", "@file", "@property", "@get", "@set", "@receiver", "@param", "@setparam", "@delegate"].includes?(annotation_name) - index = start_index + 1 - while index < @tokens.size - token = @tokens[index] - token_type = token.type - - if token_type == :NEWLINE - index += 1 - elsif token_type == :COLON - index += 1 - elsif token_type == :IDENTIFIER - index += 1 - elsif token_type == :LANGLE - index = find_bracket_partner(index) - return nil if index.nil? - index += 1 - index += 1 if @tokens[index].type == :QUEST - elsif token_type == :LPAREN || token_type == :LSQUARE - index = find_bracket_partner(index) - return nil if index.nil? - index += 1 - else - while index > 0 && @tokens[index].type == :NEWLINE - index -= 1 - end - return index - end - end - return index - elsif annotation_name.starts_with?("@") - index = start_index + 1 - while index < @tokens.size - token = @tokens[index] - token_type = token.type - - if token_type == :NEWLINE - index += 1 - elsif token_type == :DOT - index += 1 - elsif token_type == :IDENTIFIER - index += 1 - elsif token_type == :LANGLE - index = find_bracket_partner(index) - return nil if index.nil? - index += 1 - index += 1 if @tokens[index].type == :QUEST - elsif token_type == :LPAREN || token_type == :LSQUARE - index = find_bracket_partner(index) - return nil if index.nil? - index += 1 - else - while index > 0 && @tokens[index].type == :NEWLINE - index -= 1 - end - return index - end - end - end - index - end - - # Find the start of an annotation - def find_annotation_start(end_index) - return nil unless end_index < @tokens.size && end_index >= 0 - cursor = end_index - 1 - - while cursor >= 0 - token = @tokens[cursor] - token_type = token.type - token_value = token.value - return cursor if token_type == :ANNOTATION || annotation_start?(token_value) - - case token_type - when :NEWLINE, :DOT, :IDENTIFIER, :COLON, :TAB, :ASSIGN - cursor -= 1 - when :RANGLE, :RPAREN, :RSQUARE - partner = find_bracket_partner(cursor) - return nil unless partner - cursor = partner - 1 - else - return nil - end - end - nil - end - - # Check if a token value indicates the start of an annotation - def annotation_start?(annotation_name) - [ - "@field", "@file", "@property", "@get", "@set", "@receiver", - "@param", "@setparam", "@delegate", - ].includes?(annotation_name) || annotation_name.starts_with?("@") - end - - # Parse annotations backwards from a given index - def parse_annotations_backwards(backward_index) - annotation_model_map = Hash(String, AnnotationModel).new - cursor = backward_index - 1 - while cursor > 0 && @tokens[cursor].type != :NEWLINE - cursor -= 1 - end - - annotation_start_index = find_annotation_start(cursor) - while !annotation_start_index.nil? - annotation_token = @tokens[annotation_start_index] - annotation_name = annotation_token.value - annotation_end = cursor - while annotation_end > 0 && @tokens[annotation_end].type == :NEWLINE - annotation_end -= 1 - end - annotation_params = [] of Array(Token) - if @tokens[annotation_start_index + 1].type == :LPAREN - annotation_params = parse_formal_parameters(annotation_start_index + 1) - end - annotation_model_map[annotation_name] = AnnotationModel.new(annotation_name, annotation_params, @tokens[annotation_start_index..annotation_end]) - annotation_start_index = find_annotation_start(annotation_start_index) - end - annotation_model_map - end - - # Parse class declarations from the tokens - def parse_classes(tokens : Array(Token), index = 0) - start_class = false - has_class_body = false - class_tokens = Array(Token).new - - paren_nesting, lcurl_nesting = 0, 0 - while index < tokens.size - token = tokens[index] - class_tokens << token if start_class - - case token.type - when :CLASS - if tokens[index + 1].type == :IDENTIFIER && !start_class - start_class = true - lcurl_nesting, paren_nesting = 0, 0 - class_tokens = Array(Token).new - class_tokens << token - elsif start_class - # Recursively parse nested classes - parse_classes(tokens, index) - end - when :LPAREN - paren_nesting += 1 - when :LCURL - lcurl_nesting += 1 - if start_class && lcurl_nesting == 1 && paren_nesting == 0 - has_class_body = true - end - when :RPAREN - paren_nesting -= 1 - when :RCURL - lcurl_nesting -= 1 - if lcurl_nesting == 0 && start_class && has_class_body - @classes_tokens << class_tokens - start_class = false - has_class_body = false - class_tokens = Array(Token).new - end - end - index += 1 - end - - if class_tokens.size > 0 && lcurl_nesting == 0 && paren_nesting == 0 - @classes_tokens << class_tokens - end - end - - # Parse modifiers for a class starting from a given index - def parse_class_modifiers(class_name_index) - index = class_name_index - 2 - modifiers = Array(String).new - while 0 < index - break if !modifier?(@tokens[index]) - modifiers << @tokens[index].value if @tokens[index].type != :NEWLINE - index -= 1 - end - modifiers - end - - # Get the class name from a set of tokens - def get_class_name(tokens : Array(Token)) - has_token = false - tokens.each do |token| - if token.index != 0 - if token.type == :CLASS || token.value == "record" - has_token = true - elsif has_token && token.type == :IDENTIFIER - return token - end - end - end - nil - end - - # Check if a token is a valid modifier - def modifier?(token) - token_value = token.value - token_type = token.type - if token_type == :NEWLINE - true - else - %w[ - enum sealed annotation data inner override lateinit - public protected private internal in out tailrec operator - infix inline external suspend const abstract final open - vararg noinline crossinline reified - ].includes?(token_value) - end - end - - # Macro to reset field variables - macro reset_fields - val_or_var = nil - parameter_type = nil - parameter_name = nil - has_assignment = false - expression = "" - access_modifier = nil - end - - # Macro to skip newlines in tokens - macro skip_newline - while index < @tokens.size && @tokens[index].type == :NEWLINE - index += 1 - end - end - - # Macro to skip type parameters in tokens - macro skip_type_parameters - skip_newline - if index < @tokens.size && @tokens[index].type == :LANGLE - partner = find_bracket_partner(index) - index = partner + 1 if partner - end - end - - # Macro to skip modifier list in tokens - macro skip_modifier_list - skip_newline - while index < @tokens.size - if @tokens[index].type == :ANNOTATION || @tokens[index].type == :AT - eindex = find_annotation_end(index) - index += eindex ? (eindex - index) + 1: 1 - elsif modifier?(@tokens[index]) - index += 1 - else - break - end - end - end - - # Macro to skip constructor keyword in tokens - macro skip_constructor - skip_newline - index += 1 if index < @tokens.size && @tokens[index].type == :CONSTRUCTOR - end - - # Macro to skip primary constructor in tokens - macro skip_primary_constructor - skip_newline - if index < @tokens.size && @tokens[index].type == :LPAREN - partner = find_bracket_partner(index) - index = partner + 1 if partner - end - end - - # Parse class parameters from the tokens - def parse_class_parameters(class_start_index) - fields = Hash(String, FieldModel).new - index = class_start_index + 1 - skip_type_parameters - skip_modifier_list - skip_constructor - skip_newline - - return fields if @tokens.size <= index - params = parse_formal_parameters(index) - params.each do |param| - access_modifier = "public" - val_or_var = nil - parameter_type = nil - parameter_name = nil - has_assignment = false - expression = "" - access_modifier = nil - - index = 0 - token_size = param.size - while index < token_size - token = param[index] - token_value = token.value - token_type = token.type - - if modifier?(token) - reset_fields - access_modifier = token_value if %i[PRIVATE PUBLIC PROTECTED INTERNAL].includes?(token_type) - else - case token_type - when :ANNOTATION - reset_fields - eindex = find_annotation_end(token.index) - index += eindex ? (eindex - token.index - 1) : 0 - when :COLON - if val_or_var && parameter_type.nil? - if (index + 4 <= token_size) && param[index + 2] == :LCURL && param[index + 3] == :IDENTIFIER - parameter_type = param[index + 3].value - else - parameter_type = param[index + 1].value - end - end - when :ASSIGN - has_assignment = true - else - if token_value == "val" || token_value == "var" - val_or_var = token_value - elsif !val_or_var.nil? && parameter_name.nil? - parameter_name = token_value - elsif has_assignment - expression += token_value - end - end - end - - index += 1 - end - - unless val_or_var.nil? || parameter_type.nil? || parameter_name.nil? - access_modifier ||= "public" - fields[parameter_name] = FieldModel.new(access_modifier, val_or_var, parameter_type, parameter_name, expression) - reset_fields - end - end - fields - end - - # Parse fields from the class body - def parse_fields_from_class_body(class_start_index, fields : Hash(String, FieldModel), methods : Hash(String, MethodModel)) - index = class_start_index + 1 - skip_type_parameters - skip_modifier_list - skip_constructor - skip_primary_constructor - skip_newline - return if @tokens.size <= index - - # Return if class body does not exist - return if @tokens[index].type != :LCURL - - end_index = find_bracket_partner(index) - return if end_index.nil? - - val_or_var = nil - parameter_type = nil - parameter_name = nil - has_assignment = false - expression = "" - access_modifier = nil - while index < end_index - token = @tokens[index] - if modifier?(token) - access_modifier = token.value if %i[PRIVATE PUBLIC PROTECTED INTERNAL].includes?(token.type) - else - case token.type - when :ANNOTATION - eindex = find_annotation_end(token.index) - index += eindex ? (eindex - token.index) : 0 - when :VAL, :VAR - val_or_var = token.value - parameter_type = nil - parameter_name = nil - when :ASSIGN - has_assignment = true - when :FUN - break - else - if val_or_var - skip_type_parameters - if token.type == :IDENTIFIER - parameter_name = token.value - if index + 2 < end_index - if @tokens[index + 1].type == :COLON - parameter_type = @tokens[index + 2].value - index += 1 - end - end - elsif token.type == :DOT - reset_fields - elsif token.type == :LPAREN - reset_fields - end - else - reset_fields - end - end - - unless val_or_var.nil? || parameter_name.nil? - access_modifier ||= "public" - parameter_type ||= "Any" - fields[parameter_name] = FieldModel.new(access_modifier, val_or_var, parameter_type, parameter_name, "") - - if parameter_name.size > 1 - camel_case_name = parameter_name[0].upcase + parameter_name[1..-1] - if methods.has_key?("get" + camel_case_name) - fields[parameter_name].has_getter = true - end - if methods.has_key?("set" + camel_case_name) - fields[parameter_name].has_setter = true - end - end - reset_fields - end - end - index += 1 - end - end - - # Parse methods from the class tokens - def parse_methods(class_tokens : Array(Token)) - methods = {} of String => MethodModel - param = Array(Token).new - current_params = [] of Array(Token) - current_annotations = {} of String => AnnotationModel - method_start_index = nil - param_start_index = nil - method_name = "" - - class_tokens.each_with_index do |token, index| - if token.type == :FUN - param_start_index = nil - param = Array(Token).new - current_params = [] of Array(Token) - - current_annotations = parse_annotations_backwards(token.index) - method_name = class_tokens[index + 1].value if class_tokens[index + 1].type == :IDENTIFIER - method_start_index = index - - current_params = parse_formal_parameters(token.index + 2) - methods[method_name] = MethodModel.new(method_name, current_params, current_annotations) - end - end - methods - end - - # Print tokens for debugging purposes - def print_tokens(tokens : Array(Token), id = "default", trace = false) - puts("\n================ #{id} ===================") - tokens.each do |token| - print("#{token.value} ") - if id == "error" - print("(#{token.type})") - end - end - - if trace - puts "" - tokens.each do |token| - print("#{token.value}(#{token.type})") - end - end - end - - # Trace parsed class and method details - def trace - @classes.each do |_class| - _class.annotations.each_key do |annotation_name| - annotation_model = _class.annotations[annotation_name] - print_tokens annotation_model.tokens, "#{_class.name} annotation" - end - - puts("\n================ class #{_class.name} ==================") - _class.fields.each_key do |field_name| - field = _class.fields[field_name] - puts("[Field] #{field.access_modifier} #{field.val_or_var} #{field.name}: #{field.type} = #{field.init_value}") - end - - _class.methods.each_key do |method_name| - _class.methods[method_name].params.each_with_index do |param_tokens, index| - print_tokens param_tokens, "#{method_name} #{index}st param" - end - - _class.methods[method_name].annotations.each_key do |annotation_name| - annotation_model = _class.methods[annotation_name].annotations[annotation_name] - print_tokens annotation_model.tokens, "#{method_name} method annotation" - end - end - end - end - - # Class to model annotations - class AnnotationModel - property name : String - property params : Array(Array(Token)) - property tokens : Array(Token) - - def initialize(@name : String, @params : Array(Array(Token)), @tokens : Array(Token)) - end - end - - # Class to model parsed classes - class ClassModel - property modifiers : Array(String) - property name : String - property methods : Hash(String, MethodModel) - property fields : Hash(String, FieldModel) - property annotations : Hash(String, AnnotationModel) - property tokens : Array(Token) - - def initialize(@modifiers, @annotations, @name, @fields, @methods, @tokens : Array(Token)) - end - - # Check if the class is an enum class - def enum_class? - modifiers.includes?("enum") - end - end - - # Class to model parsed methods - class MethodModel - property name : String - property params : Array(Array(Token)) - property annotations : Hash(String, AnnotationModel) - - def initialize(@name, @params, @annotations) - end - end - - # Class to model parsed fields - class FieldModel - property access_modifier : String - property type : String - property name : String - property val_or_var : String - property init_value : String - property? has_getter : Bool - property? has_setter : Bool - - def initialize(@access_modifier, @val_or_var, @type, @name, @init_value) - @has_getter = true - @has_setter = val_or_var == "var" - end - - def has_getter=(value : Bool) - @has_getter = value - end - - def has_setter=(value : Bool) - @has_setter = value - end - - def to_s - l = "#{@access_modifier} " - l += "static " if @is_static - l += "final " if @is_final - l += "#{@type} #{@name}" - l += " = \"#{@init_value}\"" if @init_value != "" - l += " (has_getter)" if @has_getter - l += " (has_setter)" if @has_setter - l - end - end -end diff --git a/src/models/endpoint.cr b/src/models/endpoint.cr index ebaccdd5..c79b7d73 100644 --- a/src/models/endpoint.cr +++ b/src/models/endpoint.cr @@ -70,10 +70,6 @@ struct Param @tags = [] of Tag end - def ==(other : Param) : Bool - @name == other.name && @value == other.value && @param_type == other.param_type - end - def add_tag(tag : Tag) @tags << tag end