diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 41750129c2..e1e30b95a5 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -50,7 +50,7 @@ jobs: # - swift,schema-swift # pgp issue - javascript-prop-types - ruby - - php + - php,schema-php - scala3,schema-scala3 - elixir,schema-elixir,graphql-elixir - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema diff --git a/CLAUDE.md b/CLAUDE.md index 3a1b7298d4..938400de68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,32 @@ # Repository conventions +## Testing + +quicktype's primary testing method is end-to-end fixture tests driven by JSON +and JSON Schema files. For each sample input, the fixture generates code for a +language, runs a driver program in `test/fixtures//` that +deserializes the sample and serializes it back, and compares the round-tripped +JSON to the input. + +- JSON inputs live in `test/inputs/json/`: `priority/` and `samples/` always + run, `misc/` is skipped under `QUICKTEST` (and for languages with + `skipMiscJSON`). +- JSON Schema inputs live in `test/inputs/schema/`: each `*.schema` comes with + `.N.json` samples and `.N.fail..json` expected-failure samples. A + fail sample must make the generated program exit nonzero; which fail + samples run is controlled by the language's `features` list. +- Per-language configuration — which inputs run (`skipJSON`, `includeJSON`, + `skipSchema`), renderer options, and `features` — lives in + `test/languages.ts`; fixtures are registered in `test/fixtures.ts`. +- Run one language's fixtures with `FIXTURE= script/test`, for example + `FIXTURE=php script/test` or `FIXTURE=schema-php script/test`. + +Any change that affects generated output MUST be covered by a JSON or JSON +Schema fixture test — by enabling existing inputs for the language or adding +new ones. Unit tests in `test/unit/` are a complement for what fixtures cannot +express (asserting that some code is *not* generated, API-level behavior, fast +local iteration) — never a substitute. + ## Releasing / version bumps Do not bump versions in any `package.json` before a release. Package manifest diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index 816f03b53b..85d4ea4ee0 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -20,21 +20,22 @@ import { type Sourcelike, maybeAnnotated } from "../../Source.js"; import { acronymStyle } from "../../support/Acronyms.js"; import { defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; -import type { - ClassProperty, - ClassType, - EnumType, - Type, +import { + type ClassProperty, + type ClassType, + type EnumType, + type Type, UnionType, } from "../../Type/index.js"; import { directlyReachableSingleNamedType, matchType, nullableFromUnion, + removeNullFromUnion, } from "../../Type/TypeUtils.js"; import type { phpOptions } from "./language.js"; -import { phpNameStyle, stringEscape } from "./utils.js"; +import { phpForbiddenClassNames, phpNameStyle, stringEscape } from "./utils.js"; export interface FunctionNames { readonly from: Name; @@ -65,11 +66,17 @@ export class PhpRenderer extends ConvenienceRenderer { super(targetLanguage, renderContext); } + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return phpForbiddenClassNames; + } + protected forbiddenForObjectProperties( _c: ClassType, _className: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + // `$this` is the only variable name PHP reserves; a property named + // "this" would produce an illegal `$this` constructor parameter. + return { names: ["this"], includeGlobalForbidden: true }; } protected makeNamedTypeNamer(): Namer { @@ -88,8 +95,10 @@ export class PhpRenderer extends ConvenienceRenderer { return this.getNameStyling("enumCaseNamingFunction"); } - protected unionNeedsName(u: UnionType): boolean { - return nullableFromUnion(u) === null; + protected unionNeedsName(_u: UnionType): boolean { + // Unions are represented inline as PHP union type declarations; + // no named type is ever emitted for them. + return false; } protected namedTypeToNameForTopLevel(type: Type): Type | undefined { @@ -247,6 +256,199 @@ export class PhpRenderer extends ConvenienceRenderer { this.emitLine("}"); } + // Union members in runtime-dispatch order: the most specific check + // first, so that e.g. an int matches an integer member before a double + // member, and class instances are tested before the stdClass check a + // map member uses. `any` goes last because it matches everything. + private sortedUnionMembers(u: UnionType): { + members: readonly Type[]; + nullType: Type | null; + } { + const order = [ + "class", + "enum", + "date-time", + "uuid", + "map", + "array", + "bool", + "integer", + "double", + "string", + ]; + const [nullType, nonNulls] = removeNullFromUnion(u, (t) => { + const i = order.indexOf(t.kind); + return i === -1 ? order.length : i; + }); + return { members: Array.from(nonNulls), nullType }; + } + + // The PHP union type declaration for a non-nullable union, e.g. + // `MixedClass|int|string` (requires PHP 8.0). With `jsonSide` the + // types are those of the decoded JSON value instead, where classes + // and maps are stdClass and enums and dates are strings. + protected phpUnionType(u: UnionType, jsonSide: boolean): Sourcelike { + const labels: Record = jsonSide + ? { + any: "mixed", + array: "array", + bool: "bool", + class: "stdClass", + "date-time": "string", + double: "float", + enum: "string", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + } + : { + any: "mixed", + array: "array", + bool: "bool", + "date-time": "DateTime", + double: "float", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + }; + const { members, nullType } = this.sortedUnionMembers(u); + const parts: Sourcelike[] = []; + const seen = new Set(); + for (const member of members) { + let hint: Sourcelike; + const label = labels[member.kind]; + if (label !== undefined) { + // Members can render to the same PHP type, like a string + // and a UUID member — mention it only once. + if (seen.has(label)) continue; + seen.add(label); + hint = label; + } else if (member.kind === "class" || member.kind === "enum") { + hint = this.nameForNamedType(member); + } else { + throw new Error( + `PHP cannot represent union member type "${member.kind}"`, + ); + } + + if (parts.length > 0) parts.push("|"); + parts.push(hint); + } + + if (nullType !== null) parts.push("|null"); + return parts; + } + + // The runtime check deciding whether a value is this union member. + // Returns null for `any`, which matches everything. With `jsonSide` + // the value is a decoded JSON value instead of a PHP-side one. + private unionMemberTypeCheck( + t: Type, + expr: Sourcelike[], + jsonSide: boolean, + ): Sourcelike[] | null { + switch (t.kind) { + case "null": + return ["is_null(", ...expr, ")"]; + case "bool": + return ["is_bool(", ...expr, ")"]; + case "integer": + return ["is_int(", ...expr, ")"]; + case "double": + // PHP integers are acceptable wherever floats are. + return ["is_float(", ...expr, ") || is_int(", ...expr, ")"]; + case "string": + case "uuid": + return ["is_string(", ...expr, ")"]; + case "date-time": + return jsonSide + ? ["is_string(", ...expr, ")"] + : [...expr, " instanceof DateTime"]; + case "enum": { + if (!jsonSide) { + return [...expr, " instanceof ", this.nameForNamedType(t)]; + } + + // On the JSON side an enum value is a string, but a plain + // string member may share the union, so check membership in + // the enum's values rather than just `is_string`. + const check: Sourcelike[] = [ + "is_string(", + ...expr, + ") && in_array(", + ...expr, + ", [", + ]; + let first = true; + for (const jsonName of (t as EnumType).cases) { + if (!first) check.push(", "); + check.push("'", stringEscape(jsonName), "'"); + first = false; + } + check.push("], true)"); + return check; + } + case "class": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "map": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof stdClass"]; + case "array": + return ["is_array(", ...expr, ")"]; + case "any": + return null; + default: + throw new Error( + `PHP cannot check for union member type "${t.kind}"`, + ); + } + } + + // Emits an if/elseif chain over the union's members, dispatching on + // the runtime type of `expr`, with `emitNoMatch` as the else branch. + private emitUnionDispatch( + u: UnionType, + expr: Sourcelike[], + jsonSide: boolean, + emitMember: (t: Type) => void, + emitNoMatch: () => void, + ): void { + const { members, nullType } = this.sortedUnionMembers(u); + const all = nullType === null ? members : [nullType, ...members]; + let first = true; + let haveCatchAll = false; + for (const member of all) { + const check = this.unionMemberTypeCheck(member, expr, jsonSide); + if (check === null) { + if (first) { + emitMember(member); + return; + } + + this.emitLine("} else {"); + this.indent(() => emitMember(member)); + haveCatchAll = true; + break; + } + + this.emitLine(first ? "if (" : "} elseif (", ...check, ") {"); + first = false; + this.indent(() => emitMember(member)); + } + + if (!haveCatchAll) { + this.emitLine("} else {"); + this.indent(emitNoMatch); + } + + this.emitLine("}"); + } + protected phpType( _reference: boolean, t: Type, @@ -261,9 +463,9 @@ export class PhpRenderer extends ConvenienceRenderer { return matchType( t, (_anyType) => - maybeAnnotated(isOptional, anyTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, anyTypeIssueAnnotation, "mixed"), (_nullType) => - maybeAnnotated(isOptional, nullTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, nullTypeIssueAnnotation, "mixed"), (_boolType) => optionalize("bool"), (_integerType) => optionalize("int"), (_doubleType) => optionalize("float"), @@ -276,7 +478,7 @@ export class PhpRenderer extends ConvenienceRenderer { const nullable = nullableFromUnion(unionType); if (nullable !== null) return this.phpType(true, nullable, true, prefix, suffix); - return this.nameForNamedType(unionType); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "time") { @@ -288,10 +490,10 @@ export class PhpRenderer extends ConvenienceRenderer { } if (transformedStringType.kind === "date-time") { - return "DateTime"; + return optionalize("DateTime"); } - return "string"; + return optionalize("string"); }, ); } @@ -305,10 +507,20 @@ export class PhpRenderer extends ConvenienceRenderer { (_integerType) => "int", (_doubleType) => "float", (_stringType) => "string", - (arrayType) => [ - this.phpDocConvertType(className, arrayType.items), - "[]", - ], + (arrayType) => { + const itemsDoc = this.phpDocConvertType( + className, + arrayType.items, + ); + if ( + arrayType.items instanceof UnionType && + nullableFromUnion(arrayType.items) === null + ) { + return ["(", itemsDoc, ")[]"]; + } + + return [itemsDoc, "[]"]; + }, (_classType) => _classType.getCombinedName(), (_mapType) => "stdClass", (enumType) => this.nameForNamedType(enumType), @@ -321,7 +533,7 @@ export class PhpRenderer extends ConvenienceRenderer { ]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -340,8 +552,8 @@ export class PhpRenderer extends ConvenienceRenderer { protected phpConvertType(className: Name, t: Type): Sourcelike { return matchType( t, - (_anyType) => "any", - (_nullType) => "null", + (_anyType) => "mixed", + (_nullType) => "mixed", (_boolType) => "bool", (_integerType) => "int", (_doubleType) => "float", @@ -356,7 +568,7 @@ export class PhpRenderer extends ConvenienceRenderer { return ["?", this.phpConvertType(className, nullable)]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, true); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -435,7 +647,19 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + false, + (member) => + this.phpToObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Union value has no matching member in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -541,12 +765,24 @@ export class PhpRenderer extends ConvenienceRenderer { this.phpFromObjConvert(className, nullable, lhs, args), ); this.emitLine("} else {"); - this.indent(() => this.emitLine("return null;")); + this.indent(() => this.emitLine(...lhs, " null;")); this.emitLine("}"); return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + true, + (member) => + this.phpFromObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Cannot deserialize union value in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -669,7 +905,7 @@ export class PhpRenderer extends ConvenienceRenderer { "", ); }); - this.emitLine("); /* ", `${idx}`, ":", args, "*/"); + this.emitLine(")", suffix, " /* ", `${idx}`, ":", args, "*/"); }, (classType) => this.emitLine( @@ -684,16 +920,30 @@ export class PhpRenderer extends ConvenienceRenderer { "*/", ), (mapType) => { - this.emitLine("$out = new stdClass();"); - this.phpSampleConvert( - className, - mapType.values, - ["$out->{'", className, "'} = "], + // An immediately-invoked closure, so the sample is an + // expression and can nest inside arrays and other maps. + this.emitLine(...lhs, " (function () {"); + this.indent(() => { + this.emitLine("$out = new stdClass();"); + this.phpSampleConvert( + className, + mapType.values, + ["$out->{'", className, "'} = "], + args, + idx, + ";", + ); + this.emitLine("return $out;"); + }); + this.emitLine( + "})()", + suffix, + " /* ", + `${idx}`, + ":", args, - idx, - ";", + "*/", ); - this.emitLine("return $out;"); }, (enumType) => this.emitLine( @@ -717,7 +967,16 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error(`union are not supported:${unionType}`); + // Any member's sample is a valid sample for the union. + const { members } = this.sortedUnionMembers(unionType); + this.phpSampleConvert( + className, + defined(members[0]), + lhs, + args, + idx, + suffix, + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -772,11 +1031,36 @@ export class PhpRenderer extends ConvenienceRenderer { matchType( t, - (_anyType) => is("defined"), + (_anyType) => { + // Every value is a valid `any`; there is nothing to check. + // (This used to call `defined()`, which tests whether a + // *constant* of the given name exists and throws for + // non-string arguments.) + }, (_nullType) => is("is_null"), (_boolType) => is("is_bool"), (_integerType) => is("is_integer"), - (_doubleType) => is("is_float"), + (_doubleType) => { + // PHP integers are acceptable wherever floats are, and + // json_decode gives an int for a whole JSON number. + this.emitBlock( + [ + "if (!is_float(", + scopeAttrName, + ") && !is_int(", + scopeAttrName, + "))", + ], + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); + }, (_stringType) => is("is_string"), (arrayType) => { is("is_array"); @@ -832,7 +1116,26 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("not implemented"); + this.emitUnionDispatch( + unionType, + [scopeAttrName], + false, + (member) => + this.phpValidate( + className, + member, + attrName, + scopeAttrName, + ), + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -1220,6 +1523,12 @@ export class PhpRenderer extends ConvenienceRenderer { p = "|| "; }, ); + if (lines.length === 0) { + // A class without properties has nothing to check. + this.emitLine("return true;"); + return; + } + lines.forEach((line, jdx) => { this.emitLine( ...line, diff --git a/packages/quicktype-core/src/language/Php/utils.ts b/packages/quicktype-core/src/language/Php/utils.ts index 457b0dfe35..fc027ca5a9 100644 --- a/packages/quicktype-core/src/language/Php/utils.ts +++ b/packages/quicktype-core/src/language/Php/utils.ts @@ -55,3 +55,99 @@ export function phpNameStyle( isStartCharacter, ); } + +// Words that cannot be used as class names in PHP, in the PascalCase form +// the type namer produces (PHP reserves them case-insensitively): keywords, +// plus the reserved type and constant names, plus the classes the generated +// code itself refers to. Class names produced from JSON property names +// like "mixed" or "class" would otherwise fail to compile. +export const phpForbiddenClassNames: readonly string[] = [ + "Abstract", + "And", + "Array", + "As", + "Bool", + "Break", + "Callable", + "Case", + "Catch", + "Class", + "Clone", + "Const", + "Continue", + "Converter", + "DateTime", + "DateTimeInterface", + "Declare", + "Default", + "Die", + "Do", + "Echo", + "Else", + "Elseif", + "Empty", + "Enddeclare", + "Endfor", + "Endforeach", + "Endif", + "Endswitch", + "Endwhile", + "Enum", + "Eval", + "Exception", + "Exit", + "Extends", + "False", + "Final", + "Finally", + "Float", + "Fn", + "For", + "Foreach", + "Function", + "Global", + "Goto", + "If", + "Implements", + "Include", + "Instanceof", + "Insteadof", + "Int", + "Interface", + "Isset", + "Iterable", + "List", + "Match", + "Mixed", + "Namespace", + "Never", + "New", + "Null", + "Object", + "Or", + "Parent", + "Print", + "Private", + "Protected", + "Public", + "Readonly", + "Require", + "Return", + "Self", + "Static", + "StdClass", + "String", + "Switch", + "Throw", + "Trait", + "True", + "Try", + "Unset", + "Use", + "Var", + "Void", + "While", + "Xor", + "Yield", + "stdClass", +]; diff --git a/test/fixtures.ts b/test/fixtures.ts index 33f9dd1c29..d9f78eaea2 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1589,6 +1589,7 @@ export const allFixtures: Fixture[] = [ new JSONSchemaFixture(languages.RustLanguage), new JSONSchemaFixture(languages.RubyLanguage), new JSONSchemaFixture(languages.PythonLanguage), + new JSONSchemaFixture(languages.PHPLanguage), new JSONSchemaFixture(languages.ElmLanguage), new JSONSchemaFixture(languages.SwiftLanguage), new JSONSchemaFixture(languages.TypeScriptLanguage), diff --git a/test/inputs/json/priority/php-mixed-union.json b/test/inputs/json/priority/php-mixed-union.json new file mode 100644 index 0000000000..ed7ec4427b --- /dev/null +++ b/test/inputs/json/priority/php-mixed-union.json @@ -0,0 +1,3 @@ +{ + "mixed": [1, "two", true, null, {"nested": "object"}] +} diff --git a/test/languages.ts b/test/languages.ts index 416927fe9a..a84d020187 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -427,6 +427,7 @@ export const RubyLanguage: Language = { "optional-union.json", "union-constructor-clash.json", "unions.json", + "php-mixed-union.json", "nbl-stats.json", "kitchen-sink.json", ], @@ -998,6 +999,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Scala3 has the same prelude-shadowing bug that this input @@ -1085,6 +1087,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", ], skipSchema: [], @@ -1137,6 +1140,7 @@ export const KotlinLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1225,6 +1229,7 @@ export const KotlinJacksonLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1487,9 +1492,37 @@ export const PHPLanguage: Language = { "uuids.json", "nested-objects.json", "bug2663.json", + // Union-heavy inputs: PHP renders non-nullable unions as inline + // PHP 8.0 union type declarations with runtime dispatch. + "unions.json", + "union-constructor-clash.json", + "combinations1.json", + "combinations2.json", + "combinations3.json", + "combinations4.json", + "nst-test-suite.json", + "kitchen-sink.json", + "list.json", + "bug427.json", + // The motivating repro for non-nullable union support: a + // heterogeneous array under a PHP-reserved-word property name. + "php-mixed-union.json", ], skipMiscJSON: true, - skipSchema: [], + skipSchema: [ + // PHP class names are case-insensitive, but the namer dedups + // case-sensitively, so this declares classes that collide (same + // reason Java and Python skip it). + "keyword-unions.schema", + // Unions are inlined as PHP union type declarations, so a + // top-level union produces no named TopLevel class for the driver. + "recursive-union-flattening.schema", + // The generated code for top-level enums is incompatible with the + // driver. + "top-level-enum.schema", + // The driver does not support top-level arrays. + "union.schema", + ], rendererOptions: {}, quickTestRendererOptions: [], sourceFiles: ["src/language/Php/index.ts"], diff --git a/test/unit/php-unions.test.ts b/test/unit/php-unions.test.ts new file mode 100644 index 0000000000..483cde8f72 --- /dev/null +++ b/test/unit/php-unions.test.ts @@ -0,0 +1,67 @@ +// PHP generation used to fail with "union are not supported" whenever the +// input contained a union that wasn't just nullable — e.g. a heterogeneous +// array like [1, "two", {"nested": "object"}]. Unions are now rendered +// inline as PHP 8.0 union type declarations, with runtime type dispatch in +// the from/to/validate converters. +import { describe, expect, test } from "vitest"; + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function phpForJSONSamples(samples: string[]): Promise { + const jsonInput = jsonInputForTargetLanguage("php"); + await jsonInput.addSource({ name: "TopLevel", samples }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ inputData, lang: "php" }); + return result.lines.join("\n"); +} + +describe("PHP union support", () => { + test("heterogeneous arrays render with runtime dispatch", async () => { + const php = await phpForJSONSamples([ + '{"mixed": [1, "two", true, null, {"nested": "object"}]}', + ]); + // Each member gets a runtime type check in the converters. + expect(php).toMatch(/is_int\(\$value\)/); + expect(php).toMatch(/is_string\(\$value\)/); + expect(php).toMatch(/is_bool\(\$value\)/); + expect(php).toMatch(/is_null\(\$value\)/); + expect(php).toMatch(/\$value instanceof \w/); + // Unmatched values fail loudly instead of being silently dropped. + expect(php).toMatch(/Cannot deserialize union value/); + }); + + test("a union property gets a PHP 8 union type declaration", async () => { + const php = await phpForJSONSamples([ + '{"v": 1}', + '{"v": "s"}', + '{"v": {"x": 1}}', + ]); + expect(php).toMatch(/private V\|int\|string \$v;/); + expect(php).toMatch(/stdClass\|int\|string \$value/); + }); + + test("nullable unions keep the ?-prefixed hint", async () => { + const php = await phpForJSONSamples(['{"a": "x"}', '{"a": null}']); + expect(php).toMatch(/private \?string \$a;/); + }); + + test("a double member accepts PHP integers", async () => { + // PHP has no union of both number types; 1 and 2.5 unify to + // double, whose runtime check must still match int values. + const php = await phpForJSONSamples(['{"n": [1, 2.5, "s"]}']); + expect(php).toMatch(/is_float\(\$value\) \|\| is_int\(\$value\)/); + }); + + test("reserved words are not used as class names", async () => { + // "mixed" is a reserved type name in PHP 8; `class Mixed` would + // fail to compile. + const php = await phpForJSONSamples(['{"mixed": {"a": 1}}']); + expect(php).not.toMatch(/class Mixed\b/); + expect(php).toMatch(/class MixedClass\b/); + }); +});