All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.squareup.kotlinpoet.FileSpec.kt Maven / Gradle / Ivy

There is a newer version: 2.0.0
Show newest version
/*
 * Copyright (C) 2015 Square, Inc.
 *
 * Licensed 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
 *
 * https://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 com.squareup.kotlinpoet

import com.squareup.kotlinpoet.AnnotationSpec.UseSiteTarget.FILE
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.net.URI
import java.nio.charset.StandardCharsets.UTF_8
import java.nio.file.Path
import javax.annotation.processing.Filer
import javax.tools.JavaFileObject
import javax.tools.JavaFileObject.Kind
import javax.tools.SimpleJavaFileObject
import javax.tools.StandardLocation
import kotlin.DeprecationLevel.HIDDEN
import kotlin.io.path.createDirectories
import kotlin.io.path.isDirectory
import kotlin.io.path.notExists
import kotlin.io.path.outputStream
import kotlin.reflect.KClass

/**
 * A Kotlin file containing top level objects like classes, objects, functions, properties, and type
 * aliases.
 *
 * Items are output in the following order:
 * - Comment
 * - Annotations
 * - Package
 * - Imports
 * - Members
 */
public class FileSpec private constructor(
  builder: Builder,
  private val tagMap: TagMap = builder.buildTagMap(),
) : Taggable by tagMap, Annotatable, TypeSpecHolder {
  override val annotations: List = builder.annotations.toImmutableList()
  override val typeSpecs: List = builder.members.filterIsInstance().toImmutableList()
  public val comment: CodeBlock = builder.comment.build()
  public val packageName: String = builder.packageName
  public val name: String = builder.name
  public val members: List = builder.members.toList()
  public val defaultImports: Set = builder.defaultImports.toSet()
  public val body: CodeBlock = builder.body.build()
  public val isScript: Boolean = builder.isScript
  private val memberImports = builder.memberImports.associateBy(Import::qualifiedName)
  private val indent = builder.indent
  private val extension = if (isScript) "kts" else "kt"

  /**
   * The relative path of the file which would be produced by a call to [writeTo].
   * This value always uses unix-style path separators (`/`).
   */
  public val relativePath: String = buildString {
    for (packageComponent in packageName.split('.').dropLastWhile { it.isEmpty() }) {
      append(packageComponent)
      append('/')
    }
    append(name)
    append('.')
    append(extension)
  }

  @Throws(IOException::class)
  public fun writeTo(out: Appendable) {
    val codeWriter = CodeWriter.withCollectedImports(
      out = out,
      indent = indent,
      memberImports = memberImports,
      emitStep = { importsCollector -> emit(importsCollector, collectingImports = true) },
    )
    emit(codeWriter, collectingImports = false)
    codeWriter.close()
  }

  @Deprecated("", level = HIDDEN)
  @JvmName("writeTo") // For binary compatibility.
  public fun oldWriteTo(directory: Path) {
    writeTo(directory)
  }

  /**
   * Writes this to [directory] as UTF-8 using the standard directory structure
   * and returns the newly output path.
   */
  @Throws(IOException::class)
  public fun writeTo(directory: Path): Path {
    require(directory.notExists() || directory.isDirectory()) {
      "path $directory exists but is not a directory."
    }
    val outputPath = directory.resolve(relativePath)
    outputPath.parent.createDirectories()
    outputPath.outputStream().bufferedWriter().use(::writeTo)
    return outputPath
  }

  @Deprecated("", level = HIDDEN)
  @JvmName("writeTo") // For binary compatibility.
  public fun oldWriteTo(directory: File) {
    writeTo(directory)
  }

  /**
   * Writes this to [directory] as UTF-8 using the standard directory structure
   * and returns the newly output file.
   */
  @Throws(IOException::class)
  public fun writeTo(directory: File): File = writeTo(directory.toPath()).toFile()

  /** Writes this to `filer`.  */
  @Throws(IOException::class)
  public fun writeTo(filer: Filer) {
    val originatingElements = members.asSequence()
      .filterIsInstance()
      .flatMap { it.originatingElements.asSequence() }
      .toSet()
    val filerSourceFile = filer.createResource(
      StandardLocation.SOURCE_OUTPUT,
      packageName,
      "$name.$extension",
      *originatingElements.toTypedArray(),
    )
    try {
      filerSourceFile.openWriter().use { writer -> writeTo(writer) }
    } catch (e: Exception) {
      try {
        filerSourceFile.delete()
      } catch (ignored: Exception) {
      }
      throw e
    }
  }

  private fun emit(codeWriter: CodeWriter, collectingImports: Boolean) {
    if (comment.isNotEmpty()) {
      codeWriter.emitComment(comment)
    }

    if (annotations.isNotEmpty()) {
      codeWriter.emitAnnotations(annotations, inline = false)
      codeWriter.emit("\n")
    }

    codeWriter.pushPackage(packageName)

    val escapedPackageName = packageName.escapeSegmentsIfNecessary()

    if (escapedPackageName.isNotEmpty()) {
      codeWriter.emitCode("package·%L\n", escapedPackageName)
      codeWriter.emit("\n")
    }

    // If we don't have default imports or are collecting them, we don't need to filter
    var isDefaultImport: (String) -> Boolean = { false }
    if (!collectingImports && defaultImports.isNotEmpty()) {
      val defaultImports = defaultImports.map(String::escapeSegmentsIfNecessary)
      isDefaultImport = { importName ->
        importName.substringBeforeLast(".") in defaultImports
      }
    }
    // Aliased imports should always appear at the bottom of the imports list.
    val (aliasedImports, nonAliasedImports) = codeWriter.imports.values
      .partition { it.alias != null }
    val imports = nonAliasedImports.asSequence().map { it.toString() }
      .filterNot(isDefaultImport)
      .toSortedSet()
      .plus(aliasedImports.map { it.toString() }.toSortedSet())

    if (imports.isNotEmpty()) {
      for (import in imports) {
        codeWriter.emitCode("import·%L", import)
        codeWriter.emit("\n")
      }
      codeWriter.emit("\n")
    }

    if (isScript) {
      codeWriter.emitCode(body)
    } else {
      members.forEachIndexed { index, member ->
        if (index > 0) codeWriter.emit("\n")
        when (member) {
          is TypeSpec -> member.emit(codeWriter, null)
          is FunSpec -> member.emit(codeWriter, null, setOf(KModifier.PUBLIC), true)
          is PropertySpec -> member.emit(codeWriter, setOf(KModifier.PUBLIC))
          is TypeAliasSpec -> member.emit(codeWriter)
          else -> throw AssertionError()
        }
      }
    }

    codeWriter.popPackage()
  }

  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other == null) return false
    if (javaClass != other.javaClass) return false
    return toString() == other.toString()
  }

  override fun hashCode(): Int = toString().hashCode()

  override fun toString(): String = buildString { writeTo(this) }

  public fun toJavaFileObject(): JavaFileObject {
    val uri = URI.create(relativePath)
    return object : SimpleJavaFileObject(uri, Kind.SOURCE) {
      private val lastModified = System.currentTimeMillis()
      override fun getCharContent(ignoreEncodingErrors: Boolean): String {
        return [email protected]()
      }

      override fun openInputStream(): InputStream {
        return ByteArrayInputStream(getCharContent(true).toByteArray(UTF_8))
      }

      override fun getLastModified() = lastModified
    }
  }

  @JvmOverloads
  public fun toBuilder(packageName: String = this.packageName, name: String = this.name): Builder {
    val builder = Builder(packageName, name, isScript)
    builder.annotations.addAll(annotations)
    builder.comment.add(comment)
    builder.members.addAll(this.members)
    builder.indent = indent
    builder.memberImports.addAll(memberImports.values)
    builder.defaultImports.addAll(defaultImports)
    builder.tags += tagMap.tags
    builder.body.add(body)
    return builder
  }

  public class Builder internal constructor(
    public val packageName: String,
    public val name: String,
    public val isScript: Boolean,
  ) : Taggable.Builder, Annotatable.Builder, TypeSpecHolder.Builder {
    override val annotations: MutableList = mutableListOf()
    internal val comment = CodeBlock.builder()
    internal val memberImports = sortedSetOf()
    internal var indent = DEFAULT_INDENT
    override val tags: MutableMap, Any> = mutableMapOf()

    public val defaultImports: MutableSet = mutableSetOf()
    public val imports: List get() = memberImports.toList()
    public val members: MutableList = mutableListOf()
    internal val body = CodeBlock.builder()

    /**
     * Add an annotation to the file.
     *
     * The annotation must either have a [`file` use-site target][AnnotationSpec.UseSiteTarget.FILE]
     * or not have a use-site target specified (in which case it will be changed to `file`).
     */
    override fun addAnnotation(annotationSpec: AnnotationSpec): Builder = apply {
      val spec = when (annotationSpec.useSiteTarget) {
        FILE -> annotationSpec
        null -> annotationSpec.toBuilder().useSiteTarget(FILE).build()
        else -> error(
          "Use-site target ${annotationSpec.useSiteTarget} not supported for file annotations.",
        )
      }
      annotations += spec
    }

    /** Adds a file-site comment. This is prefixed to the start of the file and different from [addBodyComment]. */
    public fun addFileComment(format: String, vararg args: Any): Builder = apply {
      comment.add(format.replace(' ', '·'), *args)
    }

    @Deprecated(
      "Use addFileComment() instead.",
      ReplaceWith("addFileComment(format, args)"),
      DeprecationLevel.ERROR,
    )
    public fun addComment(format: String, vararg args: Any): Builder = addFileComment(format, *args)

    public fun clearComment(): Builder = apply {
      comment.clear()
    }

    override fun addType(typeSpec: TypeSpec): Builder = apply {
      if (isScript) {
        body.add("%L", typeSpec)
      } else {
        members += typeSpec
      }
    }

    //region Overrides for binary compatibility
    @Suppress("RedundantOverride")
    override fun addTypes(typeSpecs: Iterable): Builder = super.addTypes(typeSpecs)
    //endregion

    public fun addFunction(funSpec: FunSpec): Builder = apply {
      require(!funSpec.isConstructor && !funSpec.isAccessor) {
        "cannot add ${funSpec.name} to file $name"
      }
      if (isScript) {
        body.add("%L", funSpec)
      } else {
        members += funSpec
      }
    }

    public fun addProperty(propertySpec: PropertySpec): Builder = apply {
      if (isScript) {
        body.add("%L", propertySpec)
      } else {
        members += propertySpec
      }
    }

    public fun addTypeAlias(typeAliasSpec: TypeAliasSpec): Builder = apply {
      if (isScript) {
        body.add("%L", typeAliasSpec)
      } else {
        members += typeAliasSpec
      }
    }

    public fun addImport(constant: Enum<*>): Builder = addImport(
      (constant as java.lang.Enum<*>).declaringClass.asClassName(),
      constant.name,
    )

    public fun addImport(`class`: Class<*>, vararg names: String): Builder = apply {
      require(names.isNotEmpty()) { "names array is empty" }
      addImport(`class`.asClassName(), names.toList())
    }

    public fun addImport(`class`: KClass<*>, vararg names: String): Builder = apply {
      require(names.isNotEmpty()) { "names array is empty" }
      addImport(`class`.asClassName(), names.toList())
    }

    public fun addImport(className: ClassName, vararg names: String): Builder = apply {
      require(names.isNotEmpty()) { "names array is empty" }
      addImport(className, names.toList())
    }

    public fun addImport(`class`: Class<*>, names: Iterable): Builder =
      addImport(`class`.asClassName(), names)

    public fun addImport(`class`: KClass<*>, names: Iterable): Builder =
      addImport(`class`.asClassName(), names)

    public fun addImport(className: ClassName, names: Iterable): Builder = apply {
      require("*" !in names) { "Wildcard imports are not allowed" }
      for (name in names) {
        memberImports += Import(className.canonicalName + "." + name)
      }
    }

    public fun addImport(packageName: String, vararg names: String): Builder = apply {
      require(names.isNotEmpty()) { "names array is empty" }
      addImport(packageName, names.toList())
    }

    public fun addImport(packageName: String, names: Iterable): Builder = apply {
      require("*" !in names) { "Wildcard imports are not allowed" }
      for (name in names) {
        memberImports += if (packageName.isNotEmpty()) {
          Import("$packageName.$name")
        } else {
          Import(name)
        }
      }
    }

    public fun addImport(import: Import): Builder = apply {
      memberImports += import
    }

    public fun clearImports(): Builder = apply {
      memberImports.clear()
    }

    public fun addAliasedImport(`class`: Class<*>, `as`: String): Builder =
      addAliasedImport(`class`.asClassName(), `as`)

    public fun addAliasedImport(`class`: KClass<*>, `as`: String): Builder =
      addAliasedImport(`class`.asClassName(), `as`)

    public fun addAliasedImport(className: ClassName, `as`: String): Builder = apply {
      memberImports += Import(className.canonicalName, `as`)
    }

    public fun addAliasedImport(
      className: ClassName,
      memberName: String,
      `as`: String,
    ): Builder = apply {
      memberImports += Import("${className.canonicalName}.$memberName", `as`)
    }

    public fun addAliasedImport(memberName: MemberName, `as`: String): Builder = apply {
      memberImports += Import(memberName.canonicalName, `as`)
    }

    /**
     * Adds a default import for the given [packageName].
     *
     * The format of this should be the qualified name of the package, e.g. `kotlin`, `java.lang`,
     * `org.gradle.api`, etc.
     */
    public fun addDefaultPackageImport(packageName: String): Builder = apply {
      defaultImports += packageName
    }

    /**
     * Adds Kotlin's standard default package imports as described
     * [here](https://kotlinlang.org/docs/packages.html#default-imports).
     */
    public fun addKotlinDefaultImports(
      includeJvm: Boolean = true,
      includeJs: Boolean = true,
    ): Builder = apply {
      defaultImports += KOTLIN_DEFAULT_IMPORTS
      if (includeJvm) {
        defaultImports += KOTLIN_DEFAULT_JVM_IMPORTS
      }
      if (includeJs) {
        defaultImports += KOTLIN_DEFAULT_JS_IMPORTS
      }
    }

    public fun indent(indent: String): Builder = apply {
      this.indent = indent
    }

    public fun addCode(format: String, vararg args: Any?): Builder = apply {
      check(isScript) {
        "addCode() is only allowed in script files"
      }
      body.add(format, *args)
    }

    public fun addNamedCode(format: String, args: Map): Builder = apply {
      check(isScript) {
        "addNamedCode() is only allowed in script files"
      }
      body.addNamed(format, args)
    }

    public fun addCode(codeBlock: CodeBlock): Builder = apply {
      check(isScript) {
        "addCode() is only allowed in script files"
      }
      body.add(codeBlock)
    }

    /** Adds a comment to the body of this script file in the order that it was added. */
    public fun addBodyComment(format: String, vararg args: Any): Builder = apply {
      check(isScript) {
        "addBodyComment() is only allowed in script files"
      }
      body.add("//·${format.replace(' ', '·')}\n", *args)
    }

    /**
     * @param controlFlow the control flow construct and its code, such as "if (foo == 5)".
     * Shouldn't contain braces or newline characters.
     */
    public fun beginControlFlow(controlFlow: String, vararg args: Any): Builder = apply {
      check(isScript) {
        "beginControlFlow() is only allowed in script files"
      }
      body.beginControlFlow(controlFlow, *args)
    }

    /**
     * @param controlFlow the control flow construct and its code, such as "else if (foo == 10)".
     * Shouldn't contain braces or newline characters.
     */
    public fun nextControlFlow(controlFlow: String, vararg args: Any): Builder = apply {
      check(isScript) {
        "nextControlFlow() is only allowed in script files"
      }
      body.nextControlFlow(controlFlow, *args)
    }

    public fun endControlFlow(): Builder = apply {
      check(isScript) {
        "endControlFlow() is only allowed in script files"
      }
      body.endControlFlow()
    }

    public fun addStatement(format: String, vararg args: Any): Builder = apply {
      check(isScript) {
        "addStatement() is only allowed in script files"
      }
      body.addStatement(format, *args)
    }

    public fun clearBody(): Builder = apply {
      check(isScript) {
        "clearBody() is only allowed in script files"
      }
      body.clear()
    }

    //region Overrides for binary compatibility
    @Suppress("RedundantOverride")
    override fun addAnnotations(annotationSpecs: Iterable): Builder =
      super.addAnnotations(annotationSpecs)

    @Suppress("RedundantOverride")
    override fun addAnnotation(annotation: ClassName): Builder = super.addAnnotation(annotation)

    @DelicateKotlinPoetApi(
      message = "Java reflection APIs don't give complete information on Kotlin types. Consider " +
        "using the kotlinpoet-metadata APIs instead.",
    )
    override fun addAnnotation(annotation: Class<*>): Builder = super.addAnnotation(annotation)

    @Suppress("RedundantOverride")
    override fun addAnnotation(annotation: KClass<*>): Builder = super.addAnnotation(annotation)
    //endregion

    public fun build(): FileSpec {
      for (annotationSpec in annotations) {
        if (annotationSpec.useSiteTarget != FILE) {
          error(
            "Use-site target ${annotationSpec.useSiteTarget} not supported for file annotations.",
          )
        }
      }
      return FileSpec(this)
    }
  }

  public companion object {
    @JvmStatic public fun get(packageName: String, typeSpec: TypeSpec): FileSpec {
      val fileName = typeSpec.name
        ?: throw IllegalArgumentException("file name required but type has no name")
      return builder(packageName, fileName).addType(typeSpec).build()
    }

    @JvmStatic public fun builder(className: ClassName): Builder {
      require(className.simpleNames.size == 1) {
        "nested types can't be used to name a file: ${className.simpleNames.joinToString(".")}"
      }
      return builder(className.packageName, className.simpleName)
    }

    @JvmStatic public fun builder(memberName: MemberName): Builder {
      return builder(memberName.packageName, memberName.simpleName)
    }

    @JvmStatic public fun builder(packageName: String, fileName: String): Builder =
      Builder(packageName, fileName, isScript = false)

    @JvmStatic public fun scriptBuilder(fileName: String, packageName: String = ""): Builder =
      Builder(packageName, fileName, isScript = true)
  }
}

internal const val DEFAULT_INDENT = "  "

private val KOTLIN_DEFAULT_IMPORTS = setOf(
  "kotlin",
  "kotlin.annotation",
  "kotlin.collections",
  "kotlin.comparisons",
  "kotlin.io",
  "kotlin.ranges",
  "kotlin.sequences",
  "kotlin.text",
)
private val KOTLIN_DEFAULT_JVM_IMPORTS = setOf("java.lang")
private val KOTLIN_DEFAULT_JS_IMPORTS = setOf("kotlin.js")




© 2015 - 2025 Weber Informatics LLC | Privacy Policy