Merge pull request #104 from kelemen/generated-jakarta-sources

FREEMARKER-218: Jakarta support
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ef7730d..0f7ae19 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,11 @@
         with:
           java-version: 16
           distribution: zulu
+      - name: Set up JDK 17
+        uses: actions/setup-java@v3
+        with:
+          java-version: 17
+          distribution: oracle
       - name: Validate Gradle wrapper
         uses: gradle/wrapper-validation-action@v1.1.0
       - name: Run Build
@@ -60,4 +65,3 @@
           name: test-reports-${{ matrix.os }}
           path: build/reports/**
           retention-days: 30
-
diff --git a/README.md b/README.md
index e282628..384c486 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@
 the source code repository. See repository locations here:
 https://freemarker.apache.org/sourcecode.html
 
-You need JDK 8 and JDK 16 to be installed
+You need JDK 8, JDK 16 and JDK 17 (only for some tests) to be installed
 (and [visible to Gradle](https://docs.gradle.org/current/userguide/toolchains.html)).
 
 Be sure that your default Java version (which Gradle should use automatically) is at
diff --git a/build.gradle.kts b/build.gradle.kts
index 6e6f1f9..84f2c3b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -20,7 +20,7 @@
 import java.io.FileOutputStream
 import java.nio.charset.StandardCharsets
 import java.nio.file.Files
-import java.util.*
+import java.util.Properties
 import java.util.stream.Collectors
 
 plugins {
@@ -58,6 +58,36 @@
     configureSourceSet("jython22")
     configureSourceSet("jython25") { enableTests() }
     configureSourceSet("core16", "16")
+
+    configureGeneratedSourceSet("jakartaServlet") {
+        val jakartaSourceGenerators = generateJakartaSources("javaxServlet")
+
+        val testSourceSet = enableTests("17").get().sources
+        val jakartaTestSourceGenerators = generateJakartaSources(
+            "javaxServletTest",
+            SourceSet.TEST_SOURCE_SET_NAME,
+            testSourceSet
+        )
+
+        (jakartaSourceGenerators + jakartaTestSourceGenerators).forEach { task ->
+            task.configure {
+                packageMappings.set(mapOf(
+                    "freemarker.ext.jsp" to "freemarker.ext.jakarta.jsp",
+                    "freemarker.ext.servlet" to "freemarker.ext.jakarta.servlet",
+                    "freemarker.cache" to "freemarker.ext.jakarta.servlet",
+                ))
+                noAutoReplacePackages.set(setOf("freemarker.cache"))
+                replacements.set(mapOf(
+                    "package freemarker.cache" to "package freemarker.ext.jakarta.servlet",
+                    "freemarker.cache.WebappTemplateLoader" to "freemarker.ext.jakarta.servlet.WebappTemplateLoader",
+                    "javax.servlet" to "jakarta.servlet",
+                    "javax.el" to "jakarta.el",
+                    "http://java.sun.com/jsp/jstl/core" to "jakarta.tags.core",
+                    "http://java.sun.com/jsp/jstl/functions" to "jakarta.tags.functions",
+                ))
+            }
+        }
+    }
 }
 
 val compileJavacc = tasks.register<freemarker.build.CompileJavaccTask>("compileJavacc") {
@@ -127,6 +157,10 @@
         extendsFrom(named("jython25CompileClasspath").get())
         extendsFrom(named("javaxServletCompileClasspath").get())
     }
+    register("javadocClasspath") {
+        extendsFrom(named("combinedClasspath").get())
+        extendsFrom(named("jakartaServletCompileClasspath").get())
+    }
 }
 
 // This source set is only needed, because the OSGI plugin supports only a single sourceSet.
@@ -230,7 +264,7 @@
         addStringOption("Xdoclint:-missing", "-quiet")
     }
 
-    classpath = files(configurations.named("combinedClasspath"))
+    classpath = files(configurations.named("javadocClasspath"))
 }
 
 fun registerManualTask(taskName: String, localeValue: String, offlineValue: Boolean) {
@@ -441,14 +475,14 @@
         val props = Properties().apply {
             // see https://reproducible-builds.org/docs/jvm/
             setProperty("buildinfo.version", "1.0-SNAPSHOT")
-            
+
             setProperty("java.version", System.getProperty("java.version"))
             setProperty("java.vendor", System.getProperty("java.vendor"))
             setProperty("os.name", System.getProperty("os.name"))
-            
+
             setProperty("source.scm.uri", "scm:git:https://git-wip-us.apache.org/repos/asf/freemarker.git")
             setProperty("source.scm.tag", "v${fmExt.versionDef.version}")
-            
+
             setProperty("build-tool", "gradle")
             setProperty("build.setup", "https://github.com/apache/freemarker/blob/2.3-gae/README.md#building-freemarker")
 
@@ -560,9 +594,13 @@
 
 val jettyVersion = "9.4.53.v20231009"
 val slf4jVersion = "1.6.1"
-val springVersion = "2.5.6.SEC03"
+val springVersion = "5.3.31"
 val tagLibsVersion = "1.2.5"
 
+val jakartaJettyVersion = "11.0.19"
+val jakartaSlf4jVersion = "2.0.9"
+val jakartaSpringVersion = "6.1.2"
+
 configurations {
     compileOnly {
         exclude(group = "xml-apis", module = "xml-apis")
@@ -599,6 +637,10 @@
 
     testImplementation(xalan)
 
+    "jakartaServletCompileOnly"("jakarta.servlet:jakarta.servlet-api:5.0.0")
+    "jakartaServletCompileOnly"("jakarta.servlet.jsp:jakarta.servlet.jsp-api:3.0.0")
+    "jakartaServletCompileOnly"("jakarta.el:jakarta.el-api:4.0.0")
+
     "javaxServletCompileOnly"("javax.servlet:javax.servlet-api:3.0.1")
     "javaxServletCompileOnly"("javax.servlet.jsp:jsp-api:2.2")
     "javaxServletCompileOnly"("javax.el:el-api:2.2")
@@ -619,6 +661,39 @@
     "javaxServletTestImplementation"("org.springframework:spring-test:${springVersion}") {
         exclude(group = "commons-logging", module = "commons-logging")
     }
+    "javaxServletTestImplementation"("org.springframework:spring-web:${springVersion}") {
+        exclude(group = "commons-logging", module = "commons-logging")
+    }
+    "javaxServletTestImplementation"("com.github.hazendaz:displaytag:2.5.3")
+
+    "jakartaServletTestImplementation"("org.eclipse.jetty:jetty-server:${jakartaJettyVersion}")
+    "jakartaServletTestImplementation"("org.eclipse.jetty:jetty-annotations:${jakartaJettyVersion}")
+    "jakartaServletTestImplementation"("org.eclipse.jetty:jetty-webapp:${jakartaJettyVersion}")
+    "jakartaServletTestImplementation"("org.eclipse.jetty:jetty-util:${jakartaJettyVersion}")
+    "jakartaServletTestImplementation"("org.eclipse.jetty:apache-jsp:${jakartaJettyVersion}")
+    // Jetty also contains the servlet-api and jsp-api and el-api classes
+
+    "jakartaServletTestImplementation"("jakarta.servlet:jakarta.servlet-api:6.0.0")
+    "jakartaServletTestImplementation"("jakarta.servlet.jsp:jakarta.servlet.jsp-api:3.0.0")
+    "jakartaServletTestImplementation"("jakarta.el:jakarta.el-api:4.0.0")
+
+    // JSP JSTL (not included in Jetty):
+    "jakartaServletTestImplementation"("com.github.hazendaz:displaytag:3.0.0-M2")
+
+    "jakartaServletTestImplementation"("org.springframework:spring-core:${jakartaSpringVersion}") {
+        exclude(group = "commons-logging", module = "commons-logging")
+    }
+    "jakartaServletTestImplementation"("org.springframework:spring-test:${jakartaSpringVersion}") {
+        exclude(group = "commons-logging", module = "commons-logging")
+    }
+    "jakartaServletTestImplementation"("org.springframework:spring-web:${jakartaSpringVersion}") {
+        exclude(group = "commons-logging", module = "commons-logging")
+    }
+
+    "jakartaServletTestRuntimeOnly"("org.slf4j:slf4j-api:${jakartaSlf4jVersion}")
+    "jakartaServletTestRuntimeOnly"("org.slf4j:log4j-over-slf4j:${jakartaSlf4jVersion}")
+    "jakartaServletTestRuntimeOnly"("org.slf4j:jcl-over-slf4j:${jakartaSlf4jVersion}")
+    "jakartaServletTestRuntimeOnly"("ch.qos.logback:logback-classic:1.3.14")
 
     "jython20CompileOnly"("jython:jython:2.1")
 
@@ -628,13 +703,6 @@
     "jython25CompileOnly"(sourceSets["jython20"].output)
     "jython25CompileOnly"("org.python:jython:2.5.0")
 
-    "testUtilsImplementation"("com.github.hazendaz:displaytag:2.5.3") {
-        exclude(group = "com.lowagie", module = "itext")
-        // We manage logging centrally:
-        exclude(group = "org.slf4j", module = "slf4j-log4j12")
-        exclude(group = "rg.slf4j", module = "jcl104-over-slf4j")
-        exclude(group = "log4j", module = "log4j")
-    }
     "testUtilsImplementation"(sourceSets.main.get().output)
     "testUtilsImplementation"("com.google.code.findbugs:annotations:3.0.0")
     "testUtilsImplementation"(libs.junit)
@@ -643,7 +711,5 @@
     "testUtilsImplementation"("commons-io:commons-io:2.7")
     "testUtilsImplementation"("com.google.guava:guava:29.0-jre")
     "testUtilsImplementation"("commons-collections:commons-collections:3.1")
-
-    // Override Java 9 incompatible version (coming from displaytag):
     "testUtilsImplementation"("commons-lang:commons-lang:2.6")
 }
diff --git a/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt
index c54c491..ddef3a3 100644
--- a/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt
+++ b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt
@@ -19,6 +19,7 @@
 
 package freemarker.build
 
+import java.util.concurrent.atomic.AtomicBoolean
 import org.gradle.api.NamedDomainObjectProvider
 import org.gradle.api.Project
 import org.gradle.api.artifacts.VersionCatalogsExtension
@@ -29,6 +30,7 @@
 import org.gradle.api.plugins.jvm.JvmTestSuiteTarget
 import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.TaskProvider
 import org.gradle.api.tasks.bundling.Jar
 import org.gradle.api.tasks.compile.JavaCompile
 import org.gradle.api.tasks.javadoc.Javadoc
@@ -36,12 +38,12 @@
 import org.gradle.jvm.toolchain.JavaToolchainService
 import org.gradle.kotlin.dsl.dependencies
 import org.gradle.kotlin.dsl.named
+import org.gradle.kotlin.dsl.register
 import org.gradle.kotlin.dsl.setProperty
 import org.gradle.kotlin.dsl.the
 import org.gradle.language.base.plugins.LifecycleBasePlugin
 import org.gradle.language.jvm.tasks.ProcessResources
 import org.gradle.testing.base.TestingExtension
-import java.util.concurrent.atomic.AtomicBoolean
 
 private const val TEST_UTILS_SOURCE_SET_NAME = "test-utils"
 
@@ -90,6 +92,7 @@
 class FreemarkerModuleDef internal constructor(
     private val context: JavaProjectContext,
     private val ext: FreemarkerRootExtension,
+    private val generated: Boolean,
     val sourceSetName: String,
     val compilerVersion: JavaLanguageVersion
 ) {
@@ -99,27 +102,71 @@
     val sourceSet = context.sourceSets.maybeCreate(sourceSetName)
 
     val sourceSetRootDirName = "freemarker-${baseDirName}"
-    val sourceSetSrcPath = "${sourceSetRootDirName}/src"
+    val sourceSetSrcPath = sourceSetRoot(context, generated, sourceSetRootDirName)
 
-    fun enableTests(testJavaVersion: String = ext.testJavaVersion) {
-        configureTests(JavaLanguageVersion.of(testJavaVersion))
+    fun generateJakartaSources(
+        baseSourceSetName: String,
+        sourceSetKind: String = SourceSet.MAIN_SOURCE_SET_NAME,
+        targetSourceSet: SourceSet = sourceSet
+    ): List<TaskProvider<JakartaSourceRootGeneratorTask>> {
+        val baseSourceSetRef = context.sourceSets.named(baseSourceSetName)
+        val taskNameClassifier = if (SourceSet.MAIN_SOURCE_SET_NAME == sourceSetKind) {
+            ""
+        } else {
+            sourceSetKind.replaceFirstChar { it.uppercaseChar() }
+        }
+
+        val generateJakartaSources = context.tasks
+            .register<JakartaSourceRootGeneratorTask>("generateJakarta${taskNameClassifier}Sources") {
+                sourceDirectory.set(baseSourceSetRef.get().java.srcDirs.single())
+                destinationDirectory.set(project.file(sourceSetSrcPath).resolve(sourceSetKind).resolve("java"))
+            }
+        targetSourceSet.java.srcDir(generateJakartaSources)
+
+        val generateJakartaResources = context.tasks
+            .register<JakartaSourceRootGeneratorTask>("generateJakarta${taskNameClassifier}Resources") {
+                sourceDirectory.set(baseSourceSetRef.get().resources.srcDirs.single())
+                destinationDirectory.set(project.file(sourceSetSrcPath).resolve(sourceSetKind).resolve("resources"))
+            }
+        targetSourceSet.resources.srcDir(generateJakartaResources)
+        return listOf(generateJakartaSources, generateJakartaResources)
     }
 
-    private fun configureTests(testJavaVersion: JavaLanguageVersion) {
-        getOrCreateTestSuiteRef().configure {
+    private fun sourceSetRoot(
+        context: JavaProjectContext,
+        generated: Boolean,
+        sourceSetRootDirName: String
+    ): String {
+        return if (generated) {
+            context.project.layout.buildDirectory.get().asFile
+                .resolve("generated")
+                .resolve(sourceSetRootDirName)
+                .toString()
+        } else {
+            "${sourceSetRootDirName}/src"
+        }
+    }
+
+    fun enableTests(testJavaVersion: String = ext.testJavaVersion) =
+        configureTests(JavaLanguageVersion.of(testJavaVersion))
+
+    private fun configureTests(testJavaVersion: JavaLanguageVersion): NamedDomainObjectProvider<JvmTestSuite> {
+        val testSuitRef = getOrCreateTestSuiteRef()
+        testSuitRef.configure {
             useJUnit(context.version("junit"))
 
             configureSources(sources, testJavaVersion)
             targets.all { configureTarget(this, sources, testJavaVersion) }
         }
+        return testSuitRef
     }
 
     private fun getOrCreateTestSuiteRef(): NamedDomainObjectProvider<JvmTestSuite> {
         val suites = context.testing.suites
-        if (main) {
-            return suites.named<JvmTestSuite>(JvmTestSuitePlugin.DEFAULT_TEST_SUITE_NAME)
+        return if (main) {
+            suites.named<JvmTestSuite>(JvmTestSuitePlugin.DEFAULT_TEST_SUITE_NAME)
         } else {
-            return suites.register("${sourceSetName}Test", JvmTestSuite::class.java)
+            suites.register("${sourceSetName}Test", JvmTestSuite::class.java)
         }
     }
 
@@ -134,9 +181,14 @@
 
     private fun configureSources(sources: SourceSet, testJavaVersion: JavaLanguageVersion) {
         sources.apply {
-            val testSrcPath = "${sourceSetSrcPath}/test"
-            java.setSrcDirs(listOf("${testSrcPath}/java"))
-            resources.setSrcDirs(listOf("${testSrcPath}/resources"))
+            if (generated) {
+                java.setSrcDirs(emptyList<String>())
+                resources.setSrcDirs(emptyList<String>())
+            } else {
+                val testSrcPath = "${sourceSetSrcPath}/test"
+                java.setSrcDirs(listOf("${testSrcPath}/java"))
+                resources.setSrcDirs(listOf("${testSrcPath}/resources"))
+            }
 
             if (!main) {
                 context.inheritCompileRuntimeAndOutput(this, sourceSet)
@@ -238,6 +290,21 @@
         }
     }
 
+    fun configureGeneratedSourceSet(
+        sourceSetName: String,
+        configuration: FreemarkerModuleDef.() -> Unit = { }
+    ) {
+        configureGeneratedSourceSet(sourceSetName, javaVersion, configuration)
+    }
+
+    fun configureGeneratedSourceSet(
+        sourceSetName: String,
+        sourceSetVersion: String,
+        configuration: FreemarkerModuleDef.() -> Unit = { }
+    ) {
+        configureSourceSet(true, sourceSetName, sourceSetVersion, configuration)
+    }
+
     fun configureSourceSet(
         sourceSetName: String,
         configuration: FreemarkerModuleDef.() -> Unit = { }
@@ -250,18 +317,31 @@
         sourceSetVersion: String,
         configuration: FreemarkerModuleDef.() -> Unit = { }
     ) {
+        configureSourceSet(false, sourceSetName, sourceSetVersion, configuration)
+    }
+
+    private fun configureSourceSet(
+        generated: Boolean,
+        sourceSetName: String,
+        sourceSetVersion: String,
+        configuration: FreemarkerModuleDef.() -> Unit = { }
+    ) {
         if (testUtilsConfigured.compareAndSet(false, true)) {
             configureTestUtils()
         }
 
         allConfiguredSourceSetNamesRef.add(sourceSetName)
 
-        FreemarkerModuleDef(context, this, sourceSetName, JavaLanguageVersion.of(sourceSetVersion)).apply {
-            val sourceSetSrcMainPath = "${sourceSetSrcPath}/main"
-
+        FreemarkerModuleDef(context, this, generated, sourceSetName, JavaLanguageVersion.of(sourceSetVersion)).apply {
             sourceSet.apply {
-                java.setSrcDirs(listOf("${sourceSetSrcMainPath}/java"))
-                resources.setSrcDirs(listOf("${sourceSetSrcMainPath}/resources"))
+                if (generated) {
+                    java.setSrcDirs(emptyList<String>())
+                    resources.setSrcDirs(emptyList<String>())
+                } else {
+                    val sourceSetSrcMainPath = "${sourceSetSrcPath}/main"
+                    java.setSrcDirs(listOf("${sourceSetSrcMainPath}/java"))
+                    resources.setSrcDirs(listOf("${sourceSetSrcMainPath}/resources"))
+                }
             }
 
             if (!main) {
diff --git a/buildSrc/src/main/kotlin/freemarker/build/JakartaSourceRootGeneratorTask.kt b/buildSrc/src/main/kotlin/freemarker/build/JakartaSourceRootGeneratorTask.kt
new file mode 100644
index 0000000..a447dc0
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/JakartaSourceRootGeneratorTask.kt
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.build
+
+import java.io.File
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.EmptyFileVisitor
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.FileVisitDetails
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.mapProperty
+import org.gradle.kotlin.dsl.setProperty
+
+open class JakartaSourceRootGeneratorTask @Inject constructor(
+    private val fs: FileSystemOperations,
+    objects: ObjectFactory
+) : DefaultTask() {
+
+    @InputDirectory
+    @SkipWhenEmpty
+    @IgnoreEmptyDirectories
+    @PathSensitive(PathSensitivity.RELATIVE)
+    val sourceDirectory = objects.directoryProperty()
+
+    @Input
+    val packageMappings = objects.mapProperty<String, String>()
+
+    @Input
+    val fileNameMappings = objects.mapProperty<String, String>()
+
+    @Input
+    val noAutoReplacePackages = objects.setProperty<String>().value(setOf())
+
+    @Input
+    val replacements = objects.mapProperty<String, String>()
+
+    @OutputDirectory
+    val destinationDirectory = objects.directoryProperty()
+
+    private fun toNewPath(oldPath: List<String>, origToNewPackage: Map<String, String>): List<String> {
+        for (oldPackageEndIndex in oldPath.size downTo 1) {
+            val oldPackageName = oldPath.subList(0, oldPackageEndIndex).joinToString(".")
+            val newPackageName = origToNewPackage[oldPackageName]
+            if (newPackageName != null) {
+                return newPackageName.split('.') + oldPath.subList(oldPackageEndIndex, oldPath.size)
+            }
+        }
+        return oldPath
+    }
+
+    private fun toPackagePath(packageName: String) = packageName.replace('.', '/')
+
+    private fun allReplacements(origToNewPackage: Map<String, String>): Map<String, String> {
+        val allReplacements = LinkedHashMap(origToNewPackage)
+        val skippedPackageReplacements = noAutoReplacePackages.get()
+        origToNewPackage.forEach { (origPackage, newPackage) ->
+            if (!skippedPackageReplacements.contains(origPackage)) {
+                allReplacements[toPackagePath(origPackage)] = toPackagePath(newPackage)
+            }
+        }
+        skippedPackageReplacements.forEach(allReplacements::remove)
+        allReplacements.putAll(replacements.get())
+        return allReplacements
+    }
+
+    @TaskAction
+    fun copyFiles() {
+        val fileNameMappingsCapture: Map<String, String> = fileNameMappings.get()
+        val origToNewPackage: Map<String, String> = packageMappings.get()
+
+        val allReplacements = allReplacements(origToNewPackage)
+
+        val destRoot = destinationDirectory.get().asFile
+        fs.delete { delete(destRoot) }
+
+        sourceDirectory.asFileTree.visit(object : EmptyFileVisitor() {
+            override fun visitFile(fileDetails: FileVisitDetails) {
+                val relPath = fileDetails.relativePath
+
+                val newPackage = toNewPath(relPath.parent.segments.asList(), origToNewPackage)
+
+                val srcPath = fileDetails.file
+                var fileContent = srcPath.readText()
+                allReplacements.forEach { (key, value) ->
+                    fileContent = fileContent.replace(key, value)
+                }
+
+                val destName = fileNameMappingsCapture[srcPath.name] ?: srcPath.name
+                val destPath = destRoot
+                    .resolve(newPackage.joinToString(File.separator))
+                    .resolve(destName)
+
+                destPath.parentFile.mkdirs()
+                destPath.writeText(applyJakartaPreprocessingBasedOnName(fileContent, destName))
+            }
+        })
+    }
+
+    private fun isJakartaDirective(line: String, directive: String): Boolean {
+        if (!line.startsWith(directive)) {
+            return false
+        }
+        return line.substring(directive.length).trim() == "jakarta"
+    }
+
+    private fun lineType(line: String, lineComment: LineCommentType): SourceLineType {
+        val uncommented = lineComment
+            .uncommentIfLineComment(line)
+            ?: return SourceLineType.OTHER
+
+        val directiveLine = uncommented.trim()
+        if (isJakartaDirective(directiveLine, "#if")) {
+            return SourceLineType.IF_START
+        }
+        if (isJakartaDirective(directiveLine, "#else")) {
+            return SourceLineType.ELSE
+        }
+        if (isJakartaDirective(directiveLine, "#endif")) {
+            return SourceLineType.ENDIF
+        }
+        return SourceLineType.OTHER
+    }
+
+    private fun extension(fileName: String): String {
+        val dotIndex = fileName.lastIndexOf('.')
+        return if (dotIndex < 0) "" else fileName.substring(dotIndex + 1)
+    }
+
+    private fun applyJakartaPreprocessingBasedOnName(input: String, fileName: String): String {
+        val lineCommentType = when (extension(fileName)) {
+            "java" -> LineCommentType.C_LIKE
+            "jsp" -> LineCommentType.JSP_LIKE
+            "xml", "html", "tld" -> LineCommentType.XML_LIKE
+            else -> return input
+        }
+        return applyJakartaPreprocessing(input, lineCommentType)
+    }
+
+    private fun applyJakartaPreprocessing(input: String, lineComment: LineCommentType): String {
+        val output = StringBuilder()
+        val modified = applyJakartaPreprocessing(input, output, lineComment)
+        return if (modified) output.toString() else input
+    }
+
+    private fun applyJakartaPreprocessing(
+        input: String,
+        output: StringBuilder,
+        lineComment: LineCommentType
+    ): Boolean {
+        var modified = false
+        var mode = SourceProcessingMode.OTHER
+
+        input.lineSequence().forEach { line ->
+            when (lineType(line, lineComment)) {
+                SourceLineType.IF_START -> {
+                    if (mode != SourceProcessingMode.OTHER) {
+                        throw IllegalStateException("Nested #if is not supported")
+                    }
+                    mode = SourceProcessingMode.JAKARTA_BLOCK
+                }
+                SourceLineType.ELSE -> {
+                    if (mode != SourceProcessingMode.JAKARTA_BLOCK) {
+                        throw IllegalStateException("Unexpected #else")
+                    }
+                    mode = SourceProcessingMode.NON_JAKARTA_BLOCK
+                }
+                SourceLineType.ENDIF -> {
+                    if (mode == SourceProcessingMode.OTHER) {
+                        throw IllegalStateException("Unexpected #endif")
+                    }
+                    mode = SourceProcessingMode.OTHER
+                }
+                SourceLineType.OTHER -> {
+                    when (mode) {
+                        SourceProcessingMode.JAKARTA_BLOCK -> {
+                            modified = true
+                            output.append(lineComment.uncomment(line))
+                            output.append('\n')
+                        }
+                        SourceProcessingMode.NON_JAKARTA_BLOCK -> {
+                            modified = true
+                        }
+                        SourceProcessingMode.OTHER -> {
+                            output.append(line)
+                            output.append('\n')
+                        }
+                    }
+                }
+            }
+        }
+        if (mode != SourceProcessingMode.OTHER) {
+            throw IllegalStateException("Unterminated #if")
+        }
+        return modified
+    }
+
+    private enum class SourceProcessingMode {
+        JAKARTA_BLOCK, NON_JAKARTA_BLOCK, OTHER
+    }
+
+    private enum class SourceLineType {
+        IF_START, ELSE, ENDIF, OTHER
+    }
+
+    private enum class LineCommentType {
+        C_LIKE {
+            override fun uncommentIfLineComment(line: String): String? =
+                uncommentIfLineComment(line, "//")
+        },
+        XML_LIKE {
+            override fun uncommentIfLineComment(line: String): String? =
+                uncommentIfLineComment(line, "<!--", "-->")
+        },
+        JSP_LIKE {
+            override fun uncommentIfLineComment(line: String): String? =
+                uncommentIfLineComment(line, "<%--", "--%>")
+        };
+
+        protected fun uncommentIfLineComment(line: String, commentOpen: String): String? {
+            val commentIndex = line.indexOf(commentOpen)
+            if (commentIndex < 0) {
+                return null
+            }
+
+            val preCommentLine = line.substring(0, commentIndex)
+            if (preCommentLine.trim().isNotEmpty()) {
+                return null
+            }
+            return preCommentLine + line.substring(commentIndex + commentOpen.length)
+        }
+
+        protected fun uncommentIfLineComment(line: String, commentOpen: String, commentClose: String): String? {
+            val noOpenLine = uncommentIfLineComment(line, commentOpen)
+                ?: return null
+
+            val commentCloseIndex = noOpenLine.lastIndexOf(commentClose)
+            if (commentCloseIndex < 0) {
+                return null
+            }
+            if (noOpenLine.substring(commentCloseIndex + commentClose.length).trim().isNotEmpty()) {
+                return null
+            }
+            return noOpenLine.substring(0, commentCloseIndex)
+        }
+
+        abstract fun uncommentIfLineComment(line: String): String?
+
+        fun uncomment(line: String): String =
+            uncommentIfLineComment(line) ?: throw IllegalArgumentException("Not a line comment: $line")
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/cache/TemplateLoaderUtils.java b/freemarker-core/src/main/java/freemarker/cache/TemplateLoaderUtils.java
index 9866232..af47ad0 100644
--- a/freemarker-core/src/main/java/freemarker/cache/TemplateLoaderUtils.java
+++ b/freemarker-core/src/main/java/freemarker/cache/TemplateLoaderUtils.java
@@ -21,7 +21,7 @@
 
 import freemarker.template.Configuration;
 
-final class TemplateLoaderUtils {
+public final class TemplateLoaderUtils {
 
     private TemplateLoaderUtils() {
         // Not meant to be instantiated
diff --git a/freemarker-core/src/main/java/freemarker/cache/URLTemplateSource.java b/freemarker-core/src/main/java/freemarker/cache/URLTemplateSource.java
index f6f44ee..bc5c885 100644
--- a/freemarker-core/src/main/java/freemarker/cache/URLTemplateSource.java
+++ b/freemarker-core/src/main/java/freemarker/cache/URLTemplateSource.java
@@ -29,7 +29,7 @@
 /**
  * Wraps a {@link URL}, and implements methods required for a typical template source.
  */
-class URLTemplateSource {
+public class URLTemplateSource {
     private final URL url;
     private URLConnection conn;
     private InputStream inputStream;
@@ -38,7 +38,7 @@
     /**
      * @param useCaches {@code null} if this aspect wasn't set in the parent {@link TemplateLoader}.
      */
-    URLTemplateSource(URL url, Boolean useCaches) throws IOException {
+    public URLTemplateSource(URL url, Boolean useCaches) throws IOException {
         this.url = url;
         this.conn = url.openConnection();
         this.useCaches = useCaches;
@@ -66,7 +66,7 @@
         return url.toString();
     }
     
-    long lastModified() {
+    public long lastModified() {
         if (conn instanceof JarURLConnection) {
           // There is a bug in sun's jar url connection that causes file handle leaks when calling getLastModified()
           // (see https://bugs.openjdk.java.net/browse/JDK-6956385).
@@ -103,7 +103,7 @@
         }
     }
 
-    InputStream getInputStream() throws IOException {
+    public InputStream getInputStream() throws IOException {
         if (inputStream != null) {
             // Ensure that the returned InputStream reads from the beginning of the resource when getInputStream()
             // is called for the second time:
@@ -118,7 +118,7 @@
         return inputStream;
     }
 
-    void close() throws IOException {
+    public void close() throws IOException {
         try {
           if (inputStream != null) {
               inputStream.close();
diff --git a/freemarker-javax-servlet/src/main/java/freemarker/cache/WebappTemplateLoader.java b/freemarker-javax-servlet/src/main/java/freemarker/cache/WebappTemplateLoader.java
index 8ff7b6c..ddd654f 100644
--- a/freemarker-javax-servlet/src/main/java/freemarker/cache/WebappTemplateLoader.java
+++ b/freemarker-javax-servlet/src/main/java/freemarker/cache/WebappTemplateLoader.java
@@ -19,6 +19,10 @@
 
 package freemarker.cache;
 
+// #if jakarta
+//import freemarker.cache.*;
+// #endif jakarta
+
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
diff --git a/freemarker-javax-servlet/src/test/java/freemarker/template/MockServletContext.java b/freemarker-javax-servlet/src/test/java/freemarker/template/MockServletContext.java
index 0ed47ec..58ccf6d 100644
--- a/freemarker-javax-servlet/src/test/java/freemarker/template/MockServletContext.java
+++ b/freemarker-javax-servlet/src/test/java/freemarker/template/MockServletContext.java
@@ -255,15 +255,15 @@
     }
 
     public void log(String arg0) {
-        
+
     }
 
     public void log(Exception arg0, String arg1) {
-        
+
     }
 
     public void log(String arg0, Throwable arg1) {
-        
+
     }
 
     public void removeAttribute(String arg0) {
@@ -271,5 +271,41 @@
 
     public void setAttribute(String arg0, Object arg1) {
     }
-    
-}
\ No newline at end of file
+
+// #if jakarta
+//    @Override
+//    public ServletRegistration.Dynamic addJspFile(String s, String s1) {
+//        return null;
+//    }
+//
+//    @Override
+//    public int getSessionTimeout() {
+//        return 0;
+//    }
+//
+//    @Override
+//    public void setSessionTimeout(int i) {
+//
+//    }
+//
+//    @Override
+//    public String getRequestCharacterEncoding() {
+//        return null;
+//    }
+//
+//    @Override
+//    public void setRequestCharacterEncoding(String s) {
+//
+//    }
+//
+//    @Override
+//    public String getResponseCharacterEncoding() {
+//        return null;
+//    }
+//
+//    @Override
+//    public void setResponseCharacterEncoding(String s) {
+//
+//    }
+// #endif jakarta
+}
diff --git a/freemarker-javax-servlet/src/test/java/freemarker/test/servlet/WebAppTestCase.java b/freemarker-javax-servlet/src/test/java/freemarker/test/servlet/WebAppTestCase.java
index ab14fb2..c69e0e1 100644
--- a/freemarker-javax-servlet/src/test/java/freemarker/test/servlet/WebAppTestCase.java
+++ b/freemarker-javax-servlet/src/test/java/freemarker/test/servlet/WebAppTestCase.java
@@ -237,7 +237,7 @@
         // Pattern of jar file names scanned for META-INF/*.tld:
         context.setAttribute(
                 ATTR_JETTY_CONTAINER_INCLUDE_JAR_PATTERN,
-                ".*taglib.*\\.jar$");
+                ".*(taglib|jsp\\.jstl).*\\.jar$");
 
         addJasperInitializer(context);