Skip to content

Commit 6842667

Browse files
authored
Merge 8624f43 into a707c19
2 parents a707c19 + 8624f43 commit 6842667

File tree

12 files changed

+473
-0
lines changed

12 files changed

+473
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
To build run `./gradlew :publishToMavenLocal`
2+
3+
To integrate: add the following to your app's gradle file:
4+
5+
```kotlin
6+
plugins {
7+
id("com.google.devtools.ksp")
8+
}
9+
dependencies {
10+
implementation("com.google.firebase:firebase-ai:<latest_version>")
11+
ksp("com.google.firebase:firebase-ai-processor:1.0.0")
12+
}
13+
```
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
plugins {
18+
kotlin("jvm")
19+
id("java-library")
20+
id("maven-publish")
21+
}
22+
23+
dependencies {
24+
testImplementation(kotlin("test"))
25+
implementation(libs.symbol.processing.api)
26+
implementation(libs.kotlinpoet.ksp)
27+
}
28+
29+
tasks.test { useJUnitPlatform() }
30+
31+
kotlin { jvmToolchain(21) }
32+
33+
publishing {
34+
publications {
35+
create<MavenPublication>("mavenKotlin") {
36+
from(components["kotlin"])
37+
groupId = "com.google.firebase"
38+
artifactId = "firebase-ai-processor"
39+
version = "1.0.0"
40+
}
41+
}
42+
repositories {
43+
maven { url = uri("m2/") }
44+
mavenLocal()
45+
}
46+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
kotlin.code.style=official
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.ai.ksp
18+
19+
import com.google.devtools.ksp.KspExperimental
20+
import com.google.devtools.ksp.processing.CodeGenerator
21+
import com.google.devtools.ksp.processing.Dependencies
22+
import com.google.devtools.ksp.processing.KSPLogger
23+
import com.google.devtools.ksp.processing.Resolver
24+
import com.google.devtools.ksp.processing.SymbolProcessor
25+
import com.google.devtools.ksp.symbol.KSAnnotated
26+
import com.google.devtools.ksp.symbol.KSAnnotation
27+
import com.google.devtools.ksp.symbol.KSClassDeclaration
28+
import com.google.devtools.ksp.symbol.KSType
29+
import com.google.devtools.ksp.symbol.KSVisitorVoid
30+
import com.google.devtools.ksp.symbol.Modifier
31+
import com.squareup.kotlinpoet.ClassName
32+
import com.squareup.kotlinpoet.CodeBlock
33+
import com.squareup.kotlinpoet.FileSpec
34+
import com.squareup.kotlinpoet.KModifier
35+
import com.squareup.kotlinpoet.ParameterizedTypeName
36+
import com.squareup.kotlinpoet.PropertySpec
37+
import com.squareup.kotlinpoet.TypeSpec
38+
import com.squareup.kotlinpoet.ksp.toClassName
39+
import com.squareup.kotlinpoet.ksp.toTypeName
40+
import com.squareup.kotlinpoet.ksp.writeTo
41+
import javax.annotation.processing.Generated
42+
43+
public class SchemaSymbolProcessor(
44+
private val codeGenerator: CodeGenerator,
45+
private val logger: KSPLogger,
46+
) : SymbolProcessor {
47+
override fun process(resolver: Resolver): List<KSAnnotated> {
48+
resolver
49+
.getSymbolsWithAnnotation("com.google.firebase.ai.annotations.Generable")
50+
.filterIsInstance<KSClassDeclaration>()
51+
.map { it to SchemaSymbolProcessorVisitor(it, resolver) }
52+
.forEach { it.second.visitClassDeclaration(it.first, Unit) }
53+
54+
return emptyList()
55+
}
56+
57+
private inner class SchemaSymbolProcessorVisitor(
58+
private val klass: KSClassDeclaration,
59+
private val resolver: Resolver,
60+
) : KSVisitorVoid() {
61+
private val numberTypes = setOf("kotlin.Int", "kotlin.Long", "kotlin.Double", "kotlin.Float")
62+
private val baseKdocRegex = Regex("^\\s*(.*?)((@\\w* .*)|\\z)", RegexOption.DOT_MATCHES_ALL)
63+
private val propertyKdocRegex =
64+
Regex("\\s*@property (\\w*) (.*?)(?=@\\w*|\\z)", RegexOption.DOT_MATCHES_ALL)
65+
66+
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
67+
val isDataClass = classDeclaration.modifiers.contains(Modifier.DATA)
68+
if (!isDataClass) {
69+
logger.error("${classDeclaration.qualifiedName} is not a data class")
70+
}
71+
val generatedSchemaFile = generateFileSpec(classDeclaration)
72+
generatedSchemaFile.writeTo(
73+
codeGenerator,
74+
Dependencies(true, classDeclaration.containingFile!!),
75+
)
76+
}
77+
78+
fun generateFileSpec(classDeclaration: KSClassDeclaration): FileSpec {
79+
return FileSpec.builder(
80+
classDeclaration.packageName.asString(),
81+
"${classDeclaration.simpleName.asString()}GeneratedSchema",
82+
)
83+
.addImport("com.google.firebase.ai.type", "Schema")
84+
.addType(
85+
TypeSpec.classBuilder("${classDeclaration.simpleName.asString()}GeneratedSchema")
86+
.addAnnotation(Generated::class)
87+
.addType(
88+
TypeSpec.companionObjectBuilder()
89+
.addProperty(
90+
PropertySpec.builder(
91+
"SCHEMA",
92+
ClassName("com.google.firebase.ai.type", "Schema"),
93+
KModifier.PUBLIC,
94+
)
95+
.mutable(false)
96+
.initializer(
97+
CodeBlock.builder()
98+
.add(
99+
generateCodeBlockForSchema(type = classDeclaration.asType(emptyList()))
100+
)
101+
.build()
102+
)
103+
.build()
104+
)
105+
.build()
106+
)
107+
.build()
108+
)
109+
.build()
110+
}
111+
112+
@OptIn(KspExperimental::class)
113+
fun generateCodeBlockForSchema(
114+
name: String? = null,
115+
description: String? = null,
116+
type: KSType,
117+
parentType: KSType? = null,
118+
guideAnnotation: KSAnnotation? = null,
119+
): CodeBlock {
120+
val parameterizedName = type.toTypeName() as? ParameterizedTypeName
121+
val className = parameterizedName?.rawType ?: type.toClassName()
122+
val kdocString = type.declaration.docString ?: ""
123+
val baseKdoc = extractBaseKdoc(kdocString)
124+
val propertyDocs = extractPropertyKdocs(kdocString)
125+
val guideClassAnnotation =
126+
type.annotations.firstOrNull() { it.shortName.getShortName() == "Guide" }
127+
val description =
128+
getDescriptionFromAnnotations(guideAnnotation, guideClassAnnotation, description, baseKdoc)
129+
val minimum = getDoubleFromAnnotation(guideAnnotation, "minimum")
130+
val maximum = getDoubleFromAnnotation(guideAnnotation, "maximum")
131+
val minItems = getIntFromAnnotation(guideAnnotation, "minItems")
132+
val maxItems = getIntFromAnnotation(guideAnnotation, "maxItems")
133+
val format = getStringFromAnnotation(guideAnnotation, "format")
134+
val builder = CodeBlock.builder()
135+
when (className.canonicalName) {
136+
"kotlin.Int" -> {
137+
builder.addStatement("Schema.integer(").indent()
138+
}
139+
"kotlin.Long" -> {
140+
builder.addStatement("Schema.long(").indent()
141+
}
142+
"kotlin.Boolean" -> {
143+
builder.addStatement("Schema.boolean(").indent()
144+
}
145+
"kotlin.Float" -> {
146+
builder.addStatement("Schema.float(").indent()
147+
}
148+
"kotlin.Double" -> {
149+
builder.addStatement("Schema.double(").indent()
150+
}
151+
"kotlin.String" -> {
152+
builder.addStatement("Schema.string(").indent()
153+
}
154+
else -> {
155+
if (className.canonicalName == "kotlin.collections.List") {
156+
val listTypeParam = type.arguments.first().type!!.resolve()
157+
val listParamCodeBlock =
158+
generateCodeBlockForSchema(type = listTypeParam, parentType = type)
159+
builder
160+
.addStatement("Schema.array(")
161+
.indent()
162+
.addStatement("items = ")
163+
.add(listParamCodeBlock)
164+
.addStatement(",")
165+
} else {
166+
builder
167+
.addStatement("Schema.obj(")
168+
.indent()
169+
.addStatement("properties = mapOf(")
170+
.indent()
171+
val properties =
172+
(type.declaration as KSClassDeclaration).getAllProperties().associate { property ->
173+
val propertyName = property.simpleName.asString()
174+
propertyName to
175+
generateCodeBlockForSchema(
176+
type = property.type.resolve(),
177+
parentType = type,
178+
description = propertyDocs[propertyName],
179+
name = propertyName,
180+
guideAnnotation =
181+
property.annotations.firstOrNull() { it.shortName.getShortName() == "Guide" },
182+
)
183+
}
184+
properties.entries.forEach {
185+
builder
186+
.addStatement("%S to ", it.key)
187+
.indent()
188+
.add(it.value)
189+
.unindent()
190+
.addStatement(", ")
191+
}
192+
builder.unindent().addStatement("),")
193+
}
194+
}
195+
}
196+
if (name != null) {
197+
builder.addStatement("title = %S,", name)
198+
}
199+
if (description != null) {
200+
builder.addStatement("description = %S,", description)
201+
}
202+
if ((minimum != null || maximum != null) && !numberTypes.contains(className.canonicalName)) {
203+
logger.warn(
204+
"${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a number type, minimum and maximum are not valid parameters to specify in @Guide"
205+
)
206+
}
207+
if (
208+
(minItems != null || maxItems != null) &&
209+
className.canonicalName != "kotlin.collections.List"
210+
) {
211+
logger.warn(
212+
"${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide"
213+
)
214+
}
215+
if (format != null && className.canonicalName != "kotlin.String") {
216+
logger.warn(
217+
"${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format is not a valid parameter to specify in @Guide"
218+
)
219+
}
220+
if (minimum != null) {
221+
builder.addStatement("minimum = %L,", minimum)
222+
}
223+
if (maximum != null) {
224+
builder.addStatement("maximum = %L,", maximum)
225+
}
226+
if (minItems != null) {
227+
builder.addStatement("minItems = %L,", minItems)
228+
}
229+
if (maxItems != null) {
230+
builder.addStatement("maxItems = %L,", maxItems)
231+
}
232+
if (format != null) {
233+
builder.addStatement("format = %S,", format)
234+
}
235+
builder.addStatement("nullable = %L)", className.isNullable).unindent()
236+
return builder.build()
237+
}
238+
239+
private fun getDescriptionFromAnnotations(
240+
guideAnnotation: KSAnnotation?,
241+
guideClassAnnotation: KSAnnotation?,
242+
description: String?,
243+
baseKdoc: String?,
244+
): String? {
245+
val guidePropertyDescription = getStringFromAnnotation(guideAnnotation, "description")
246+
247+
val guideClassDescription = getStringFromAnnotation(guideClassAnnotation, "description")
248+
249+
return guidePropertyDescription ?: guideClassDescription ?: description ?: baseKdoc
250+
}
251+
252+
private fun getDoubleFromAnnotation(
253+
guideAnnotation: KSAnnotation?,
254+
doubleName: String,
255+
): Double? {
256+
val guidePropertyDoubleValue =
257+
guideAnnotation
258+
?.arguments
259+
?.firstOrNull { it.name?.getShortName()?.equals(doubleName) == true }
260+
?.value as? Double
261+
if (guidePropertyDoubleValue == null || guidePropertyDoubleValue == -1.0) {
262+
return null
263+
}
264+
return guidePropertyDoubleValue
265+
}
266+
267+
private fun getIntFromAnnotation(guideAnnotation: KSAnnotation?, intName: String): Int? {
268+
val guidePropertyIntValue =
269+
guideAnnotation
270+
?.arguments
271+
?.firstOrNull { it.name?.getShortName()?.equals(intName) == true }
272+
?.value as? Int
273+
if (guidePropertyIntValue == null || guidePropertyIntValue == -1) {
274+
return null
275+
}
276+
return guidePropertyIntValue
277+
}
278+
279+
private fun getStringFromAnnotation(
280+
guideAnnotation: KSAnnotation?,
281+
stringName: String,
282+
): String? {
283+
val guidePropertyStringValue =
284+
guideAnnotation
285+
?.arguments
286+
?.firstOrNull { it.name?.getShortName()?.equals(stringName) == true }
287+
?.value as? String
288+
if (guidePropertyStringValue.isNullOrEmpty()) {
289+
return null
290+
}
291+
return guidePropertyStringValue
292+
}
293+
294+
private fun extractBaseKdoc(kdoc: String): String? {
295+
return baseKdocRegex.matchEntire(kdoc)?.groups?.get(1)?.value?.trim().let {
296+
if (it.isNullOrEmpty()) null else it
297+
}
298+
}
299+
300+
private fun extractPropertyKdocs(kdoc: String): Map<String, String> {
301+
return propertyKdocRegex
302+
.findAll(kdoc)
303+
.map { it.groups[1]!!.value to it.groups[2]!!.value.replace("\n", "").trim() }
304+
.toMap()
305+
}
306+
}
307+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.ai.ksp
18+
19+
import com.google.devtools.ksp.processing.SymbolProcessor
20+
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
21+
import com.google.devtools.ksp.processing.SymbolProcessorProvider
22+
23+
public class SchemaSymbolProcessorProvider : SymbolProcessorProvider {
24+
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
25+
return SchemaSymbolProcessor(environment.codeGenerator, environment.logger)
26+
}
27+
}

0 commit comments

Comments
 (0)