diff --git a/java/arcs/core/data/testdata/BUILD b/java/arcs/core/data/testdata/BUILD index 06d43663208..b7222ed3fd8 100644 --- a/java/arcs/core/data/testdata/BUILD +++ b/java/arcs/core/data/testdata/BUILD @@ -23,6 +23,7 @@ arcs_manifest_proto( arcs_kt_plan( name = "example_plan", - src = "Example.arcs", + src = "WriterReaderExample.arcs", + package = "arcs.core.data.testdata", visibility = ["//visibility:public"], ) diff --git a/java/arcs/core/data/testdata/WriterReaderExample.arcs b/java/arcs/core/data/testdata/WriterReaderExample.arcs new file mode 100644 index 00000000000..2be6bd2dd52 --- /dev/null +++ b/java/arcs/core/data/testdata/WriterReaderExample.arcs @@ -0,0 +1,21 @@ +particle Reader + data: reads Thing {name: Text} + +particle Writer + data: writes Thing {name: Text} + +@trigger + launch startup + arcId writingArcId +recipe Ingestion + thing: create persistent 'my-handle-id' + Writer + data: writes thing + +@trigger + launch startup + arcId readingArcId +recipe Consumption + data: map 'my-handle-id' + Reader + data: reads data diff --git a/src/tools/kotlin-generation-utils.ts b/src/tools/kotlin-generation-utils.ts index 5c43036098d..9def446ad2f 100644 --- a/src/tools/kotlin-generation-utils.ts +++ b/src/tools/kotlin-generation-utils.ts @@ -33,32 +33,32 @@ export class KotlinGenerationUtils { * * @param name name of the function * @param args list of arguments to the function - * @param emptyName alternative name for the function with empty arguments. * @param startIndent (optional) starting indentation level. + * @param emptyName alternative name for the function with empty arguments. */ - applyFun(name: string, args: string[], emptyName: string = name, startIndent: number = 0): string { + applyFun(name: string, args: string[], startIndent: number = 0, emptyName: string = name): string { if (args.length === 0) return `${emptyName}()`; return `${name}(${this.joinWithIndents(args, startIndent + name.length + 2)})`; } /** Formats `mapOf` with correct indentation and defaults. */ mapOf(args: string[], startIndent: number = 0): string { - return this.applyFun('mapOf', args, 'emptyMap', startIndent); + return this.applyFun('mapOf', args, startIndent, 'emptyMap'); } /** Formats `mutableMapOf` with correct indentation and defaults. */ mutableMapOf(args: string[], startIndent: number = 0): string { - return this.applyFun('mutableMapOf', args, 'mutableMapOf', startIndent); + return this.applyFun('mutableMapOf', args, startIndent, 'mutableMapOf'); } /** Formats `listOf` with correct indentation and defaults. */ listOf(args: string[], startIndent: number = 0): string { - return this.applyFun('listOf', args, 'emptyList', startIndent); + return this.applyFun('listOf', args, startIndent, 'emptyList'); } /** Formats `setOf` with correct indentation and defaults. */ setOf(args: string[], startIndent: number = 0): string { - return this.applyFun('setOf', args, 'emptySet', startIndent); + return this.applyFun('setOf', args, startIndent, 'emptySet'); } /** @@ -70,7 +70,12 @@ export class KotlinGenerationUtils { joinWithIndents(items: string[], extraIndent: number = 0): string { const candidate = items.join(', '); if (extraIndent + candidate.length <= this.pref.lineLength) return candidate; - return `\n${leftPad(items.join(',\n'), this.pref.indent)}\n`; + return `\n${this.indent(items.join(',\n'))}\n`; + } + + /** Indent a codeblock with the preferred indentation. */ + indent(block: string): string { + return leftPad(block, this.pref.indent); } } @@ -82,3 +87,11 @@ export function leftPad(input: string, indent: number, skipFirst: boolean = fals .join('\n'); } +/** Format a Kotlin string. */ +export function quote(s: string) { return `"${s}"`; } + +/** Produces import statement if target is not within the same package. */ +export function tryImport(importName: string, packageName: string): string { + const nonWild = importName.replace('.*', ''); + return packageName === nonWild ? '' : `import ${importName}`; +} diff --git a/src/tools/plan-generator.ts b/src/tools/plan-generator.ts new file mode 100644 index 00000000000..bee59230fe1 --- /dev/null +++ b/src/tools/plan-generator.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2020 Google LLC. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * Code distributed by Google as part of this project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import {Recipe} from '../runtime/recipe/recipe.js'; +import {Type} from '../runtime/type.js'; +import {Particle} from '../runtime/recipe/particle.js'; +import {KotlinGenerationUtils, quote, tryImport} from './kotlin-generation-utils.js'; +import {HandleConnection} from '../runtime/recipe/handle-connection.js'; +import {StorageKey} from '../runtime/storageNG/storage-key.js'; +import {Direction} from '../runtime/manifest-ast-nodes.js'; + +const ktUtils = new KotlinGenerationUtils(); + +export class PlanGeneratorError extends Error { + constructor(message: string) { + super(message); + this.name = 'PlanGeneratorError'; + } +} + +/** Generates plan objects from resolved recipes. */ +export class PlanGenerator { + constructor(private resolvedRecipes: Recipe[], private scope: string) { + } + + /** Generates a Kotlin file with plan classes derived from resolved recipes. */ + generate(): string { + const planOutline = [ + this.fileHeader(), + ...this.createPlans(), + this.fileFooter() + ]; + + return planOutline.join('\n'); + } + + /** Converts a resolved recipe into a `Plan` object. */ + createPlans(): string[] { + return this.resolvedRecipes.map(recipe => { + const planName = `${recipe.name}Plan`; + + const particles = recipe.particles.map(p => this.createParticle(p)); + + const start = `object ${planName} : `; + return `${start}${ktUtils.applyFun('Plan', [ktUtils.listOf(particles)], start.length)}`; + }); + } + + /** Generates a Kotlin `Plan.Particle` instantiation from a Particle. */ + createParticle(particle: Particle): string { + const spec = particle.spec; + const location = (spec && (spec.implBlobUrl || (spec.implFile && spec.implFile.replace('/', '.')))) || ''; + + const connectionMappings = Object.entries(particle.connections) + .map(([key, conn]) => `"${key}" to ${this.createHandleConnection(conn)}`); + + return ktUtils.applyFun('Particle', [ + quote(particle.name), + quote(location), + ktUtils.mapOf(connectionMappings, 12) + ]); + } + + /** Generates a Kotlin `Plan.HandleConnection` from a HandleConnection. */ + createHandleConnection(connection: HandleConnection): string { + const storageKey = this.createStorageKey(connection.handle.storageKey); + const mode = this.createDirection(connection.direction); + const type = this.createType(connection.type); + const ttl = 'null'; + + return ktUtils.applyFun('HandleConnection', [storageKey, mode, type, ttl], 24); + } + + /** Generates a Kotlin `HandleMode` from a Direction. */ + createDirection(direction: Direction): string { + switch (direction) { + case 'reads': return 'HandleMode.Read'; + case 'writes': return 'HandleMode.Write'; + case 'reads writes': return 'HandleMode.ReadWrite'; + default: throw new PlanGeneratorError( + `HandleConnection direction '${direction}' is not supported.`); + } + } + + /** Generates a Kotlin `StorageKey` from a StorageKey. */ + createStorageKey(storageKey: StorageKey | undefined): string { + return `StorageKeyParser.parse("${(storageKey || '').toString()}")`; + } + + /** Generates a Kotlin `core.arc.type.Type` from a Type. */ + // TODO(alxr): Implement + createType(type: Type): string { + switch (type.tag) { + case 'Collection': + break; + case 'Entity': + break; + case 'Handle': + break; + case 'Reference': + break; + case 'Singleton': + break; + case 'TypeVariable': + break; + case 'Arc': + case 'BigCollection': + case 'Count': + case 'Interface': + case 'Slot': + case 'Tuple': + default: + throw Error(`Type of ${type.tag} is not supported.`); + } + return 'null'; + } + + fileHeader(): string { + return `\ +/* ktlint-disable */ +@file:Suppress("PackageName", "TopLevelName") + +package ${this.scope} + +// +// GENERATED CODE -- DO NOT EDIT +// + +${tryImport('arcs.core.data.*', this.scope)} +${tryImport('arcs.core.storage.*', this.scope)} +`; + } + + fileFooter(): string { + return ``; + } +} diff --git a/src/tools/recipe2plan-cli.ts b/src/tools/recipe2plan-cli.ts index 7e061794df7..ff608655418 100644 --- a/src/tools/recipe2plan-cli.ts +++ b/src/tools/recipe2plan-cli.ts @@ -14,8 +14,8 @@ import {Runtime} from '../runtime/runtime.js'; import {recipe2plan} from './recipe2plan.js'; const opts = minimist(process.argv.slice(2), { - string: ['outdir', 'outfile'], - alias: {d: 'outdir', f: 'outfile'}, + string: ['outdir', 'outfile', 'package'], + alias: {d: 'outdir', f: 'outfile', p: 'package'}, default: {outdir: '.'} }); @@ -30,6 +30,7 @@ Description Options --outfile, -f output filename; required --outdir, -d output directory; defaults to '.' + --package, -p kotlin package. --help usage info `); process.exit(0); @@ -40,6 +41,12 @@ if (!opts.outfile) { process.exit(1); } + +if (!opts.package) { + console.error(`Parameter --package is required.`); + process.exit(1); +} + // TODO(alxr): Support generation from multiple manifests if (opts._.length > 1) { console.error(`Only a single manifest is allowed`); @@ -56,7 +63,7 @@ async function main() { Runtime.init('../..'); fs.mkdirSync(opts.outdir, {recursive: true}); - const plans = await recipe2plan(opts._[0]); + const plans = await recipe2plan(opts._[0], opts.package); const outPath = path.join(opts.outdir, opts.outfile); console.log(outPath); diff --git a/src/tools/recipe2plan.ts b/src/tools/recipe2plan.ts index b03977723df..6aac1da6531 100644 --- a/src/tools/recipe2plan.ts +++ b/src/tools/recipe2plan.ts @@ -8,35 +8,23 @@ * http://polymer.github.io/PATENTS.txt */ import {Runtime} from '../runtime/runtime.js'; -import {Recipe} from '../runtime/recipe/recipe.js'; import {StorageKeyRecipeResolver} from './storage-key-recipe-resolver.js'; +import {PlanGenerator} from './plan-generator.js'; /** * Generates Kotlin Plans from recipes in an arcs manifest. * * @param path path/to/manifest.arcs + * @param scope kotlin package name * @return Generated Kotlin code. */ -export async function recipe2plan(path: string): Promise { +export async function recipe2plan(path: string, scope: string): Promise { const manifest = await Runtime.parseFile(path); const recipes = await (new StorageKeyRecipeResolver(manifest)).resolve(); - const plans = await generatePlans(recipes); + const generator = new PlanGenerator(recipes, scope); - return plans.join('\n'); + return generator.generate(); } - - -/** - * Converts each resolved recipes into a Kotlin Plan class. - * - * @param resolutions A series of resolved recipes. - * @return List of generated Kotlin plans - */ -async function generatePlans(resolutions: Recipe[]): Promise { - // TODO Implement - return ['']; -} - diff --git a/src/tools/tests/goldens/WriterReaderExample.kt b/src/tools/tests/goldens/WriterReaderExample.kt new file mode 100755 index 00000000000..fb8adcf0504 --- /dev/null +++ b/src/tools/tests/goldens/WriterReaderExample.kt @@ -0,0 +1,39 @@ +/* ktlint-disable */ +@file:Suppress("PackageName", "TopLevelName") + +package arcs.core.data.testdata + +// +// GENERATED CODE -- DO NOT EDIT +// + +import arcs.core.data.* +import arcs.core.storage.* + +object IngestionPlan : Plan( + listOf( + Particle( + "Writer", + "", + mapOf( + "data" to HandleConnection(StorageKeyParser.parse(""), HandleMode.Write, null, null) + ) + ) + ) +) +object ConsumptionPlan : Plan( + listOf( + Particle( + "Reader", + "", + mapOf( + "data" to HandleConnection( + StorageKeyParser.parse("ramdisk://"), + HandleMode.Read, + null, + null + ) + ) + ) + ) +) diff --git a/src/tools/tests/plan-generator-tests.ts b/src/tools/tests/plan-generator-tests.ts new file mode 100644 index 00000000000..9f5092ef136 --- /dev/null +++ b/src/tools/tests/plan-generator-tests.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2020 Google LLC. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * Code distributed by Google as part of this project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ + +import {PlanGenerator} from '../plan-generator.js'; +import {assert} from '../../platform/chai-node.js'; + +describe('recipe2plan', () => { + describe('plan-generator', () => { + it('imports arcs.core.data when the package is different', () => { + const generator = new PlanGenerator([], 'some.package'); + + const actual = generator.fileHeader(); + + assert.include(actual, 'import arcs.core.data.*'); + }); + it('does not import arcs.core.data when the package is the same', () => { + const generator = new PlanGenerator([], 'arcs.core.data'); + + const actual = generator.fileHeader(); + + assert.notInclude(actual, 'import arcs.core.data.*'); + }); + }); +}); diff --git a/src/tools/tests/recipe2plan-test.ts b/src/tools/tests/recipe2plan-test.ts new file mode 100644 index 00000000000..3f46d3ca9d6 --- /dev/null +++ b/src/tools/tests/recipe2plan-test.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2020 Google LLC. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * Code distributed by Google as part of this project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import {assert} from '../../platform/chai-web.js'; +import {fs} from '../../platform/fs-web.js'; +import {recipe2plan} from '../recipe2plan.js'; + +describe('recipe2plan', () => { + it('generates plans from recipes in a manifest', async () => { + assert.deepStrictEqual( + await recipe2plan('java/arcs/core/data/testdata/WriterReaderExample.arcs', 'arcs.core.data.testdata'), + fs.readFileSync('src/tools/tests/goldens/WriterReaderExample.kt', 'utf8') + ); + }); +}); diff --git a/third_party/java/arcs/build_defs/internal/kotlin.bzl b/third_party/java/arcs/build_defs/internal/kotlin.bzl index 5e5656f0f2c..4f55f015c07 100644 --- a/third_party/java/arcs/build_defs/internal/kotlin.bzl +++ b/third_party/java/arcs/build_defs/internal/kotlin.bzl @@ -322,12 +322,13 @@ def arcs_kt_android_test_suite(name, manifest, package, srcs = None, tags = [], data = data, ) -def arcs_kt_plan(name, src, deps = [], out = None, visibility = None): +def arcs_kt_plan(name, src, package, deps = [], out = None, visibility = None): """Converts recipes in manifests into Kotlin Plans. Args: name: the name of the target to create src: an Arcs manifest file + package: name of kotlin package for generated file deps: list of dependencies (other manifests) out: the name of the output artifact (a Kotlin file). visibility: list of visibilities @@ -339,8 +340,8 @@ def arcs_kt_plan(name, src, deps = [], out = None, visibility = None): srcs = [src], outs = outs, deps = deps, - progress_message = "Producing Plans", - sigh_cmd = "recipe2plan --outdir $(dirname {OUT}) --outfile $(basename {OUT}) {SRC}", + progress_message = "Generating Plans", + sigh_cmd = "recipe2plan --outdir $(dirname {OUT}) --outfile $(basename {OUT}) --package " + package + " {SRC}", ) def arcs_kt_jvm_test_suite(name, package, srcs = None, tags = [], deps = [], data = []):