Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import org.jacodb.ets.model.EtsNegExpr
import org.jacodb.ets.model.EtsNeverType
import org.jacodb.ets.model.EtsNewArrayExpr
import org.jacodb.ets.model.EtsNewExpr
import org.jacodb.ets.model.EtsTemplateObjectExpr
import org.jacodb.ets.model.EtsNewClassExpr
import org.jacodb.ets.model.EtsSpreadExpansionExpr
import org.jacodb.ets.model.EtsNopStmt
Expand Down Expand Up @@ -374,6 +375,13 @@ class EtsMethodBuilder(

is NewClassExprDto -> EtsNewClassExpr(signature.toEtsClassSignature())

is TemplateObjectExprDto -> EtsTemplateObjectExpr(
siteId = siteId,
cooked = cooked,
raw = raw,
type = type.toEtsType(),
)

is NewArrayExprDto -> EtsNewArrayExpr(
elementType = elementType.toEtsType(),
size = size.toEtsEntity(),
Expand Down
9 changes: 9 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ data class NewClassExprDto(
get() = ClassValueTypeDto(signature)
}

@Serializable
@SerialName("TemplateObjectExpr")
data class TemplateObjectExprDto(
val siteId: String,
val cooked: List<String?>,
val raw: List<String>,
override val type: TypeDto,
) : ExprDto

@Serializable
@SerialName("NewArrayExpr")
data class NewArrayExprDto(
Expand Down
25 changes: 25 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import org.jacodb.ets.toArrayType
interface EtsExpr : EtsEntity {
interface Visitor<out R> {
fun visit(expr: EtsNewExpr): R
fun visit(expr: EtsTemplateObjectExpr): R {
if (this is Default) return defaultVisit(expr)
error("Cannot handle ${expr::class.java.simpleName}: $expr")
}
fun visit(expr: EtsNewClassExpr): R {
if (this is Default) return defaultVisit(expr)
error("Cannot handle ${expr::class.java.simpleName}: $expr")
Expand Down Expand Up @@ -93,6 +97,7 @@ interface EtsExpr : EtsEntity {

interface Default<out R> : Visitor<R> {
override fun visit(expr: EtsNewExpr): R = defaultVisit(expr)
override fun visit(expr: EtsTemplateObjectExpr): R = defaultVisit(expr)
override fun visit(expr: EtsNewClassExpr): R = defaultVisit(expr)
override fun visit(expr: EtsSpreadExpansionExpr): R = defaultVisit(expr)
override fun visit(expr: EtsToPropertyKeyExpr): R = defaultVisit(expr)
Expand Down Expand Up @@ -214,6 +219,26 @@ data class EtsNewClassExpr(
override fun <R> accept(visitor: EtsExpr.Visitor<R>): R = visitor.visit(this)
}

/**
* ECMAScript GetTemplateObject. Evaluation returns the same cooked array for this [siteId]
* within one execution realm. Both arrays are frozen; the cooked array's non-enumerable,
* non-writable `raw` property points to the raw array. A null cooked entry means undefined.
*/
data class EtsTemplateObjectExpr(
val siteId: String,
val cooked: List<String?>,
val raw: List<String>,
override val type: EtsType,
) : EtsExpr {
init {
require(cooked.size == raw.size) { "Template cooked and raw arrays must have the same length" }
}

override fun toString(): String = "templateObject($siteId)"

override fun <R> accept(visitor: EtsExpr.Visitor<R>): R = visitor.visit(this)
}

data class EtsNewArrayExpr(
val elementType: EtsType,
val size: EtsEntity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr
import org.jacodb.ets.model.EtsNegExpr
import org.jacodb.ets.model.EtsNewArrayExpr
import org.jacodb.ets.model.EtsNewExpr
import org.jacodb.ets.model.EtsTemplateObjectExpr
import org.jacodb.ets.model.EtsNewClassExpr
import org.jacodb.ets.model.EtsSpreadExpansionExpr
import org.jacodb.ets.model.EtsNopStmt
Expand Down Expand Up @@ -323,4 +324,7 @@ private object EntityGetOperands : EtsEntity.Visitor<Sequence<EtsEntity>> {

override fun visit(expr: EtsNewClassExpr): Sequence<EtsEntity> =
emptySequence()

override fun visit(expr: EtsTemplateObjectExpr): Sequence<EtsEntity> =
emptySequence()
}
5 changes: 5 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr
import org.jacodb.ets.model.EtsNegExpr
import org.jacodb.ets.model.EtsNewArrayExpr
import org.jacodb.ets.model.EtsNewExpr
import org.jacodb.ets.model.EtsTemplateObjectExpr
import org.jacodb.ets.model.EtsNewClassExpr
import org.jacodb.ets.model.EtsSpreadExpansionExpr
import org.jacodb.ets.model.EtsNopStmt
Expand Down Expand Up @@ -499,4 +500,8 @@ abstract class AbstractHandler : EtsEntity.Visitor<Unit>, EtsStmt.Visitor<Unit>
handle(expr)
}

final override fun visit(expr: EtsTemplateObjectExpr) {
handle(expr)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2022 UnitTestBot contributors (utbot.org)
* <p>
* 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.jacodb.ets.test

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jacodb.ets.dto.AssignStmtDto
import org.jacodb.ets.dto.CallExprDto
import org.jacodb.ets.dto.EtsFileDto
import org.jacodb.ets.dto.PtrCallExprDto
import org.jacodb.ets.dto.RawValueDto
import org.jacodb.ets.dto.TemplateObjectExprDto
import org.jacodb.ets.dto.dtoModule
import org.jacodb.ets.dto.toEtsFile
import org.jacodb.ets.model.EtsAssignStmt
import org.jacodb.ets.model.EtsPtrCallExpr
import org.jacodb.ets.model.EtsTemplateObjectExpr
import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME
import org.jacodb.ets.utils.EtsIrProvider
import org.jacodb.ets.utils.generateEtsIR
import org.jacodb.ets.utils.getOperands
import org.junit.jupiter.api.Test
import kotlin.io.path.createTempDirectory
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertTrue


class EtsTsTaggedTemplateTest {
private fun frontendRoundTrip(source: String): EtsFileDto {
val input = createTempDirectory("ts-calls-test").resolve("test.ts")
input.writeText(source)

val output = generateEtsIR(
input,
isProject = false,
timeout = null,
provider = EtsIrProvider.TS_FRONTEND,
)
val dto = EtsFileDto.loadFromJson(output.readText())
return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto))
}

private fun assignments(dto: EtsFileDto, method: String): List<AssignStmtDto> =
dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME }
.methods.single { it.signature.name == method }.body!!.cfg.blocks
.flatMap { it.stmts }.filterIsInstance<AssignStmtDto>()

@Test
fun `tagged template objects preserve raw cooked undefined sites and method receivers`() {
val dto = frontendRoundTrip(
"""
class Tagger {
tag(parts: TemplateStringsArray, value: number): string { return parts.raw[0] + value; }
}
export function value(tagger: Tagger): string { return tagger.tag`\unicode${'$'}{1}`; }
""".trimIndent(),
)
val stmts = assignments(dto, "value")
val creation = stmts.single { it.right is TemplateObjectExprDto }
val template = creation.right as TemplateObjectExprDto
val call = stmts.single { it.right is PtrCallExprDto }.right as PtrCallExprDto

assertEquals(listOf(null, ""), template.cooked)
assertEquals(listOf("\\unicode", ""), template.raw)
assertTrue(template.siteId.isNotEmpty())
assertEquals(creation.left, call.args.first())
assertTrue(call.receiver != null)
assertTrue(stmts.none { it.right is RawValueDto })

val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME }
.methods.single { it.name == "value" }.cfg.stmts.filterIsInstance<EtsAssignStmt>()
val modelTemplate = model.single { it.rhv is EtsTemplateObjectExpr }.rhv as EtsTemplateObjectExpr
val modelCall = model.single { it.rhv is EtsPtrCallExpr }.rhv as EtsPtrCallExpr
assertEquals(template.siteId, modelTemplate.siteId)
assertEquals(template.cooked, modelTemplate.cooked)
assertEquals(template.raw, modelTemplate.raw)
assertTrue(modelTemplate.getOperands().none())
assertTrue(modelCall.getOperands().any { it == modelCall.receiver })
}
}
9 changes: 9 additions & 0 deletions jacodb-ets/ts-frontend/src/dto/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type ImmediateDto = LocalDto | ConstantDto | ClassValueRefDto;

export type ExprDto =
| NewExprDto
| TemplateObjectExprDto
| NewClassExprDto
| SpreadExpansionExprDto
| ToPropertyKeyExprDto
Expand Down Expand Up @@ -120,6 +121,14 @@ export interface NewClassExprDto {
signature: ClassSignatureDto;
}

export interface TemplateObjectExprDto {
readonly _: "TemplateObjectExpr";
siteId: string;
cooked: (string | null)[]; // null represents undefined for an invalid template escape.
raw: string[];
type: TypeDto;
}

export interface NewArrayExprDto {
readonly _: "NewArrayExpr";
elementType: TypeDto;
Expand Down
99 changes: 97 additions & 2 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export class ExprLowerer {
if (ts.isTemplateExpression(node)) {
return this.lowerTemplate(node);
}
if (ts.isTaggedTemplateExpression(node)) {
return this.lowerTaggedTemplate(node);
}
if (ts.isTypeOfExpression(node)) {
return { _: "TypeOfExpr", arg: this.lowerToImmediate(node.expression) };
}
Expand Down Expand Up @@ -1316,6 +1319,90 @@ export class ExprLowerer {
return value;
}

private lowerTaggedTemplate(node: ts.TaggedTemplateExpression): ValueDto {
const tag = unwrapTransparentExpression(node.tag);
const substitutions = ts.isTemplateExpression(node.template)
? node.template.templateSpans.map((span) => span.expression)
: [];
const unsupportedBeforeTag = this.unsupportedExpressionCount;
let ptr: LocalDto | undefined;
let receiver: LocalDto | undefined;
let declaringClass = UNKNOWN_CLASS_SIGNATURE;
let name = "%tag";
let staticCall = false;

if (ts.isPropertyAccessExpression(tag) || ts.isElementAccessExpression(tag)) {
// Property Get and key coercion can themselves mutate the source binding.
receiver = this.snapshotToLocal(tag.expression);
const key = ts.isPropertyAccessExpression(tag)
? constant(tag.name.text, STRING_TYPE)
: this.lowerPropertyKey(tag.argumentExpression);
ptr = this.materialize({
_: "PropertyRef",
instance: receiver,
key,
type: this.safeTypeOf(tag),
}, this.safeTypeOf(tag));
name = ts.isPropertyAccessExpression(tag) ? tag.name.text : "%tag";
} else {
const resolved = this.resolveCalleeDeclaration(node);
if (ts.isIdentifier(tag) && resolved !== undefined && ts.isFunctionDeclaration(resolved)
&& isProjectFile(resolved) && isScopeFunctionDeclaration(resolved)) {
declaringClass = {
name: DEFAULT_ARK_CLASS_NAME,
declaringFile: this.m.ctx.fileSignatureFor(resolved.getSourceFile()),
};
const namespace = this.m.converter.namespaceSignatureOf(resolved);
if (namespace !== undefined) declaringClass.declaringNamespace = namespace;
name = tag.text;
staticCall = true;
} else {
ptr = this.snapshotToLocal(node.tag, substitutions);
if (ts.isIdentifier(tag)) name = tag.text;
}
}

if (this.unsupportedExpressionCount !== unsupportedBeforeTag) {
throw new LoweringError("template tag cannot be represented in EtsIR");
}

const literals = ts.isTemplateExpression(node.template)
? [node.template.head, ...node.template.templateSpans.map((span) => span.literal)]
: [node.template];
const raw = literals.map(templateRawText);
const cooked = literals.map((literal, index) => {
// The public compiler API reports invalid escapes in an untagged token.
// Such escapes are legal in tagged templates, whose cooked entry is undefined.
const probe = ts.transpileModule(`const value = \`${raw[index]}\`;`, { reportDiagnostics: true });
return probe.diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)
? null
: literal.text;
});
const templateType: TypeDto = {
_: "ArrayType",
elementType: cooked.includes(null)
? { _: "UnionType", types: [STRING_TYPE, UNDEFINED_TYPE] }
: STRING_TYPE,
dimensions: 1,
};
const file = this.m.ctx.fileSignatureFor(node.getSourceFile());
const parts = this.materialize({
_: "TemplateObjectExpr",
siteId: JSON.stringify([file.projectName, file.fileName, node.template.getStart()]),
cooked,
raw,
type: templateType,
}, templateType);
const args = [parts, ...this.lowerArguments(substitutions)];
const method = this.methodSignatureForCall(node, name, declaringClass);

if (staticCall) return { _: "StaticCallExpr", method, args };
if (ptr === undefined) throw new LoweringError("template tag has no callable value");
return receiver === undefined
? { _: "PtrCallExpr", ptr, method, args }
: { _: "PtrCallExpr", ptr, receiver, method, args };
}

/** `a${x}b` -> chain of string `+` binops. */
private lowerTemplate(node: ts.TemplateExpression): ValueDto {
let acc: ImmediateDto = constant(node.head.text, STRING_TYPE);
Expand Down Expand Up @@ -1672,7 +1759,7 @@ export class ExprLowerer {
return { _: "ClassType", signature: { name, declaringFile: UNKNOWN_FILE_SIGNATURE } };
}

private resolveCalleeDeclaration(node: ts.CallExpression): ts.Declaration | undefined {
private resolveCalleeDeclaration(node: ts.CallExpression | ts.TaggedTemplateExpression): ts.Declaration | undefined {
try {
const signature = this.m.checker.getResolvedSignature(node);
return signature?.getDeclaration();
Expand All @@ -1683,7 +1770,7 @@ export class ExprLowerer {

/** Method signature for a call site, resolved through the checker when possible. */
private methodSignatureForCall(
node: ts.CallExpression,
node: ts.CallExpression | ts.TaggedTemplateExpression,
name: string,
declaringClass: ClassSignatureDto,
): MethodSignatureDto {
Expand Down Expand Up @@ -1759,6 +1846,14 @@ export function constant(value: string, type: TypeDto): ConstantDto {
return { _: "Constant", value, type };
}

function templateRawText(literal: ts.TemplateLiteralLikeNode): string {
const text = literal.getText();
const suffixLength = literal.kind === ts.SyntaxKind.TemplateHead || literal.kind === ts.SyntaxKind.TemplateMiddle
? 2
: 1;
return text.slice(1, -suffixLength).replace(/\r\n?/g, "\n");
}

/** Preserve the source spelling only when TypeScript normalizes a non-finite literal. */
function numericConstantText(node: ts.NumericLiteral): string {
return Number.isFinite(Number(node.text)) ? node.text : node.getText();
Expand Down
8 changes: 8 additions & 0 deletions jacodb-ets/ts-frontend/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ValueDto } from "./dto/values";

const EXPR_KINDS = new Set([
"NewExpr",
"TemplateObjectExpr",
"NewClassExpr",
"SpreadExpansionExpr",
"ToPropertyKeyExpr",
Expand Down Expand Up @@ -383,6 +384,11 @@ function validateValue(
err(`${ctx}: spread expectedCount must be a non-negative integer`);
}
break;
case "TemplateObjectExpr":
if (value.cooked.length !== value.raw.length) {
err(`${ctx}: template cooked and raw arrays have different lengths`);
}
break;
default:
break;
}
Expand All @@ -401,6 +407,8 @@ export function valueOperands(value: ValueDto): ValueDto[] {
return [value.iterable];
case "NewClassExpr":
return [];
case "TemplateObjectExpr":
return [];
case "NewArrayExpr":
return [value.size];
case "DeleteExpr":
Expand Down
Loading