diff --git a/composer.json b/composer.json index 34500ce9..6c6118de 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "facebook/hack-codegen", - "description": "Hack Codegen is a library for programatically generating Hack code", + "description": "Hack Codegen is a library for programmatically generating Hack code", "keywords": ["code generation", "Hack"], "require": { "hhvm": "^4.80", diff --git a/src/CodegenClass.hack b/src/CodegenClass.hack index b777a4dc..ffd3e93f 100644 --- a/src/CodegenClass.hack +++ b/src/CodegenClass.hack @@ -32,6 +32,7 @@ final class CodegenClass extends CodegenClassish { private string $declComment = ''; private bool $isFinal = false; private bool $isAbstract = false; + private bool $isXHP = false; private ?CodegenConstructor $constructor = null; /** @selfdocumenting */ @@ -46,6 +47,12 @@ final class CodegenClass extends CodegenClassish { return $this; } + /** @selfdocumenting */ + public function setIsXHP(bool $value = true): this { + $this->isXHP = $value; + return $this; + } + /** Set the parent class of the generated class. */ public function setExtends(string $name): this { return $this->setExtendsf('%s', $name); @@ -148,10 +155,11 @@ final class CodegenClass extends CodegenClassish { $generics_dec = $this->buildGenericsDeclaration(); $builder->addWithSuggestedLineBreaksf( - '%s%s%s%s%s', + '%s%s%s%s%s%s', $this->declComment, $this->isAbstract ? 'abstract ' : '', $this->isFinal ? 'final ' : '', + $this->isXHP ? 'xhp ' : '', 'class '.$this->name.$generics_dec, $this->extendsClass !== null ? HackBuilder::DELIMITER.'extends '.$this->extendsClass @@ -172,6 +180,7 @@ final class CodegenClass extends CodegenClassish { protected function appendBodyToBuilder(HackBuilder $builder): void { $this->buildTraits($builder); $this->buildConsts($builder); + $this->buildXHPAttributes($builder); $this->buildVars($builder); $this->buildManualDeclarations($builder); $this->buildConstructor($builder); diff --git a/src/CodegenClassish.hack b/src/CodegenClassish.hack index 03c28e1f..fe52da12 100644 --- a/src/CodegenClassish.hack +++ b/src/CodegenClassish.hack @@ -10,7 +10,7 @@ namespace Facebook\HackCodegen; use namespace HH\Lib\{C, Str, Vec}; -use namespace Facebook\HackCodegen\_Private\C as CP; +use namespace Facebook\HackCodegen\_Private\{C as CP, Vec as VecP}; /** * Abstract class to generate class-like definitions. @@ -33,6 +33,7 @@ abstract class CodegenClassish implements ICodeBuilderRenderer { protected vec $methods = vec[]; private vec $traits = vec[]; protected vec $consts = vec[]; + protected vec $xhpAttributes = vec[]; protected vec $vars = vec[]; private bool $isConsistentConstruct = false; protected bool $hasManualFooter = false; @@ -153,6 +154,20 @@ abstract class CodegenClassish implements ICodeBuilderRenderer { return $this; } + /** @selfdocumenting */ + public function addXhpAttribute(CodegenXHPAttribute $attribute): this { + $this->xhpAttributes[] = $attribute; + return $this; + } + + /** @selfdocumenting */ + public function addXhpAttributes(Traversable $attributes): this { + foreach($attributes as $attr) { + $this->addXhpAttribute($attr); + } + return $this; + } + /** @selfdocumenting */ public function setDocBlock(string $comment): this { $this->docBlock = $comment; @@ -315,6 +330,22 @@ abstract class CodegenClassish implements ICodeBuilderRenderer { } } + protected function buildXHPAttributes(HackBuilder $builder): void { + if (C\is_empty($this->xhpAttributes)) { + return; + } + $builder->ensureNewLine(); + $builder->addLine('attribute')->indent(); + + $attributes = $this->xhpAttributes; + $last = VecP\pop_backx(inout $attributes); + foreach($attributes as $attr) { + $builder->addRenderer($attr); + $builder->addLine(','); + } + $builder->addRenderer($last)->addLine(';')->unindent(); + } + protected function buildVars(HackBuilder $builder): void { if (C\is_empty($this->vars)) { return; @@ -380,7 +411,7 @@ abstract class CodegenClassish implements ICodeBuilderRenderer { $generated_from = $this->generatedFrom ? $this->generatedFrom->render() : null; - $doc_block_parts = \array_filter(varray[$this->docBlock, $generated_from]); + $doc_block_parts = Vec\filter_nulls(vec[$this->docBlock, $generated_from]); if ($doc_block_parts) { $builder->addDocBlock(Str\join($doc_block_parts, "\n\n")); diff --git a/src/CodegenFactoryTrait.hack b/src/CodegenFactoryTrait.hack index a720ac2f..7044597a 100644 --- a/src/CodegenFactoryTrait.hack +++ b/src/CodegenFactoryTrait.hack @@ -229,4 +229,15 @@ trait CodegenFactoryTrait implements ICodegenFactory { final public function codegenNewtype(string $name): CodegenType { return (new CodegenType($this->getConfig(), $name))->newType(); } + + final public function codegenXHPAttribute(string $name): CodegenXHPAttribute { + return new CodegenXHPAttribute($this->getConfig(), $name); + } + + final public function codegenXHPAttributef( + Str\SprintfFormatString $format, + mixed ...$args + ): CodegenXHPAttribute { + return $this->codegenXHPAttribute(\vsprintf($format, $args)); + } } diff --git a/src/CodegenTrait.hack b/src/CodegenTrait.hack index 361587ce..54750539 100644 --- a/src/CodegenTrait.hack +++ b/src/CodegenTrait.hack @@ -63,6 +63,7 @@ final class CodegenTrait extends CodegenClassish { $this->buildRequires($builder); $this->buildTraits($builder); $this->buildConsts($builder); + $this->buildXHPAttributes($builder); $this->buildVars($builder); $this->buildManualDeclarations($builder); $this->buildMethods($builder); diff --git a/src/CodegenXHPAttribute.hack b/src/CodegenXHPAttribute.hack new file mode 100644 index 00000000..d28fc740 --- /dev/null +++ b/src/CodegenXHPAttribute.hack @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +namespace Facebook\HackCodegen; + +use namespace HH\Lib\Str; + +/** + * Generate code for an xhp attribute. Please don't use this class directly; + * instead use the function ICodegenFactory->codegenAttribute. E.g.: + * + * ICodegenFactory->codegenAttribute('src') + * ->setType('string') + * ->setInlineComment('A script src must be a valid URI') + * ->render(); + */ +final class CodegenXHPAttribute implements ICodeBuilderRenderer { + + use HackBuilderRenderer; + + private ?string $comment; + private ?string $type; + private ?string $value; + private ?XHPAttributeDecorator $decorator; + + public function __construct( + protected IHackCodegenConfig $config, + private string $name, + ) {} + + public function getName(): string { + return $this->name; + } + + public function getType(): ?string { + return $this->type; + } + + public function getValue(): mixed { + return $this->value; + } + + public function setDecorator(?XHPAttributeDecorator $decorator): this { + invariant( + $decorator is null || $this->value is null, + 'XHP attributes with a default value can not have an %s decorator', + xhp_attribute_decorator_to_string($decorator), + ); + $this->decorator = $decorator; + return $this; + } + + public function setInlineComment(string $comment): this { + $this->comment = $comment; + return $this; + } + + /** + * Set the type of the member var. In Hack, if it's nullable + * you should prepend the question mark, e.g. "?string". + * XHP enums should be avoided, but you can specify "enum { 'foo' }" + * as a literal string if you need it. + */ + public function setType(string $type): this { + $this->type = $type; + return $this; + } + + public function setTypef( + Str\SprintfFormatString $format, + mixed ...$args + ): this { + return $this->setType(\vsprintf($format, $args)); + } + + /** + * Set the initial value for the variable. You can pass numbers, strings, + * arrays, etc, and it will generate the code to render those values. + */ + public function setValue( + T $value, + IHackBuilderValueRenderer $renderer, + ): this { + invariant( + $this->decorator is null, + 'XHP attributes with an %s decorator can not have a default value', + xhp_attribute_decorator_to_string($this->decorator), + ); + $this->value = $renderer->render($this->config, $value); + return $this; + } + + public function appendToBuilder(HackBuilder $builder): HackBuilder { + $value = $this->value; + + return $builder + ->addDocBlock($this->comment) + ->addIf($this->type is nonnull, $this->type.' ') + ->add($this->name) + ->addIf($this->value is nonnull, ' = '.$value) + ->addIf( + $this->decorator is nonnull, + ' '. + xhp_attribute_decorator_to_string( + $this->decorator ?? XHPAttributeDecorator::REQUIRED, + ), + ); + } + +} diff --git a/src/ICodegenFactory.hack b/src/ICodegenFactory.hack index fec45e64..a7d61eb5 100644 --- a/src/ICodegenFactory.hack +++ b/src/ICodegenFactory.hack @@ -201,7 +201,7 @@ interface ICodegenFactory { public function codegenProperty(string $name): CodegenProperty; /** - * Generate a class or trait property, using a %-placehodler format string + * Generate a class or trait property, using a %-placeholder format string * for the property name. * * @see codegenProperty @@ -307,4 +307,22 @@ interface ICodegenFactory { * @see codegenType */ public function codegenNewtype(string $name): CodegenType; + + /** + * Generate a class of trait xhp attribute. + * + * @see codegenXHPAttributef + */ + public function codegenXHPAttribute(string $name): CodegenXHPAttribute; + + /** + * Generate a class or trait xhp attribute, using a %-placeholder format + * string for the attribute name. + * + * @see codegenXHPAttribute + */ + public function codegenXHPAttributef( + Str\SprintfFormatString $format, + mixed ...$args + ): CodegenXHPAttribute; } diff --git a/src/XHPAttributeDecorator.hack b/src/XHPAttributeDecorator.hack new file mode 100644 index 00000000..05c11633 --- /dev/null +++ b/src/XHPAttributeDecorator.hack @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +namespace Facebook\HackCodegen; + +enum XHPAttributeDecorator: int { + REQUIRED = 0; + LATE_INIT = 1; +} + +function xhp_attribute_decorator_to_string( + XHPAttributeDecorator $decorator, +): string { + switch ($decorator) { + case XHPAttributeDecorator::REQUIRED: + return '@required'; + case XHPAttributeDecorator::LATE_INIT: + return '@lateinit'; + } +} diff --git a/tests/CodegenClassTest.codegen b/tests/CodegenClassTest.codegen index 7b73f98f..b1babe54 100644 --- a/tests/CodegenClassTest.codegen +++ b/tests/CodegenClassTest.codegen @@ -150,3 +150,20 @@ class JKRowling IRonWeasley { } +!@#$%codegentest:testXHPClassWithAttributes +xhp class a { + + const string BETWEEN_CONSTS = ''; + attribute + /** + * The web is a magical place where a string with a set structure can be + * the key to visiting some remote place on the internet where you can find + * content made by other people. + */ + string href @required, + string target = 'about:blank', + string hreflang @lateinit; + + private null $andProps; +} + diff --git a/tests/CodegenClassTest.hack b/tests/CodegenClassTest.hack index 4c99b60a..7c092a19 100644 --- a/tests/CodegenClassTest.hack +++ b/tests/CodegenClassTest.hack @@ -293,4 +293,61 @@ final class CodegenClassTest extends CodegenBaseTest { expect($code)->toContainSubstring('Tt as Xx'); expect($code)->toContainSubstring('Tsingle'); } + + public function testXHPClassWithAttributes(): void { + $cgf = $this->getCodegenFactory(); + $code = $cgf->codegenClass('a') + ->setIsXHP() + ->addConstant( + $cgf->codegenClassConstant('BETWEEN_CONSTS') + ->setType('string') + ->setValue('', HackBuilderValues::export()), + ) + ->addProperty($cgf->codegenProperty('andProps')->setType('null')) + ->addXhpAttribute( + $cgf->codegenXHPAttribute('href') + ->setType('string') + ->setInlineComment( + 'The web is a magical place where a string with a set '. + 'structure can be the key to visiting some remote place '. + 'on the internet where you can find content made by other people.', + ) + ->setDecorator(XHPAttributeDecorator::REQUIRED), + ) + ->addXhpAttribute( + $cgf->codegenXHPAttribute('target') + ->setType('string') + ->setValue('about:blank', HackBuilderValues::export()), + ) + ->addXhpAttribute( + $cgf->codegenXHPAttribute('hreflang') + ->setType('string') + ->setDecorator(XHPAttributeDecorator::LATE_INIT), + ) + ->render(); + + expect_with_context(static::class, $code)->toBeUnchanged(); + } + + public function testThrowsWhenSettingDecoratorWhenDefaultValueIsSet(): void { + $cgf = $this->getCodegenFactory(); + + $attr = $cgf->codegenXHPAttribute('explodes') + ->setType('string') + ->setValue('default', HackBuilderValues::export()); + + expect(() ==> $attr->setDecorator(XHPAttributeDecorator::LATE_INIT)) + ->toThrow(InvariantException::class, '@lateinit decorator'); + } + + public function testThrowsWhenSettingDefaultValueWhenDecoratorIsSet(): void { + $cgf = $this->getCodegenFactory(); + + $attr = $cgf->codegenXHPAttribute('explodes') + ->setType('string') + ->setDecorator(XHPAttributeDecorator::LATE_INIT); + + expect(() ==> $attr->setValue('default', HackBuilderValues::export())) + ->toThrow(InvariantException::class, 'default value'); + } }