/*
* 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\{C, Str, Vec};
enum ContainerType: string {
PHP_ARRAY = 'array';
DICT = 'dict';
VEC = 'vec';
KEYSET = 'keyset';
MAP = 'Map';
IMM_MAP = 'ImmMap';
VECTOR = 'Vector';
IMM_VECTOR = 'ImmVector';
SET = 'Set';
IMM_SET = 'ImmSet';
SHAPE_TYPE = 'shape';
}
/**
* Class to facilitate building code. It has methods for some common patterns
* used to generate code. It also deals with indentation and new lines.
*/
final class HackBuilder extends BaseCodeBuilder {
/**
* This method lets you call Multiline methods and also allows you to
* suggest line breaks. It first tries to fit the call in a single line, then
* by breaking at suggested line breaks.
* If these don't materialize, it falls back to multi line calling and
* uses suggested line breaks for each line individually.
*
* One more thing that is different from vanilla Multilinecall is the
* parameter which allows you to tell the function if closeStatement has
* to be included in the call. This is important as it changes the way
* we return code.
*/
public function addMultilineCall(
string $func_call_line,
Traversable $params,
bool $include_close_statement = true,
): this {
// Mark that the call is inside a function
$this->setIsInsideFunction();
// Get the max_length. Substracting 4 as Multiline call happens inside a
// method.
$max_length = $this->getMaxCodeLength() - 4;
// Let's put everything in a single line
$args = '('.Str\join($params, ', ').')';
$composite_line = $func_call_line.$args;
// Ignore suggested line breaks within individual args; otherwise we could
// split in the middle of arguments rather than after each parameter.
$composite_line_no_breaks =
$func_call_line.\str_replace(self::DELIMITER, ' ', $args);
if ($include_close_statement) {
$composite_line = $composite_line.";\n";
$composite_line_no_breaks = $composite_line_no_breaks.";\n";
}
$clone_builder = $this->getClone();
$clone_builder->addWithSuggestedLineBreaks($composite_line_no_breaks);
if (!self::checkIfLineIsTooLong($clone_builder->getCode(), $max_length)) {
return $this->addWithSuggestedLineBreaks($composite_line);
}
$this
->addWithSuggestedLineBreaks($func_call_line.'(')
->newLine()
->indent()
->addLinesWithSuggestedLineBreaks(Vec\map($params, $line ==> $line.','))
->unindent()
->add(')');
if ($include_close_statement) {
$this->closeStatement();
}
return $this;
}
public function addValue(T $value, IHackBuilderValueRenderer $r): this {
return $this->add($r->render($this->config, $value));
}
/**
* Add a string that is auto-wrapped to not exceed the maximum length.
* The following lines will have a level of indentation added. Example:
*
* return 'First line of the long code'.
* 'Second line of the long code';
*/
public function addWrappedString(
string $line,
?int $max_length = null,
): this {
return $this->addWrappedStringImpl($line, $max_length, true);
}
/**
* Add a string that is auto-wrapped to not exceed the maximum length.
* The following lines will have the same level of indentation as the first
* one. Example:
*
* $this->callMethod(
* 'First line of the long code'.
* 'Second line of the long code'
* );
*/
public function addWrappedStringNoIndent(
string $line,
?int $max_length = null,
): this {
return $this->addWrappedStringImpl($line, $max_length, false);
}
private function addWrappedStringImpl(
string $line,
?int $max_length = null,
bool $indent_non_first_lines = true,
): this {
$max_length = $max_length !== null
? $max_length
: // subtract 3 for the two quotes and . operator
$this->getMaxCodeLength() - 3;
$lines = $this->splitString($line, $max_length, /*preserve_space*/ true);
if (!$lines) {
return $this;
}
$this->add(_Private\normalized_var_export(C\first($lines)));
if (C\count($lines) === 1) {
return $this;
}
// If we have multiple line segments to add, add all the
// inbetween ones with the concat operator
$this->add('.')->newLine();
if ($indent_non_first_lines) {
$this->indent();
}
$lines
|> Vec\slice($$, 1, C\count($lines) - 2)
|> Vec\map(
$$,
$line ==> $this->addLine(_Private\normalized_var_export($line).'.'),
);
// And then add the last
$this->add(_Private\normalized_var_export(C\last($lines)));
if ($indent_non_first_lines) {
$this->unindent();
}
return $this;
}
public function addReturn(
T $value,
IHackBuilderValueRenderer $renderer,
): this {
return $this->add('return ')->addValue($value, $renderer)->addLine(';');
}
public function addReturnf(
Str\SprintfFormatString $value,
mixed ...$args
): this {
return
$this->addReturn(\vsprintf($value, $args), HackBuilderValues::literal());
}
public function addReturnVoid(): this{
return $this->addLine('return;');
}
public function addAssignment(
string $var_name,
T $value,
IHackBuilderValueRenderer $renderer,
): this {
return $this->addWithSuggestedLineBreaksf(
"%s =\0%s;\n",
$var_name,
$renderer->render($this->config, $value),
);
}
/**
* Open a brace in the current line and start a new line
* with one more level of indentation.
*/
public function openBrace(): this {
return $this->addLine(' {')->indent();
}
public function openContainer(ContainerType $type): this {
switch ($type) {
case ContainerType::DICT:
case ContainerType::KEYSET:
case ContainerType::VEC:
$container_sign = '[';
break;
case ContainerType::IMM_MAP:
case ContainerType::IMM_SET:
case ContainerType::IMM_VECTOR:
case ContainerType::MAP:
case ContainerType::SET:
case ContainerType::VECTOR:
$container_sign = ' {';
break;
case ContainerType::SHAPE_TYPE:
case ContainerType::PHP_ARRAY:
$container_sign = '(';
break;
}
return $this->addLine(((string)$type).$container_sign)->indent();
}
/**
* Close a brace in a new line and sets one less level of indentation.
*/
public function closeBrace(): this {
return $this->ensureNewLine()->unindent()->addLine('}');
}
public function closeContainer(ContainerType $type): this {
switch ($type) {
case ContainerType::DICT:
case ContainerType::KEYSET:
case ContainerType::VEC:
$container_sign = ']';
break;
case ContainerType::IMM_MAP:
case ContainerType::IMM_SET:
case ContainerType::IMM_VECTOR:
case ContainerType::MAP:
case ContainerType::SET:
case ContainerType::VECTOR:
$container_sign = '}';
break;
case ContainerType::SHAPE_TYPE:
case ContainerType::PHP_ARRAY:
$container_sign = ')';
break;
}
return $this->unindent()->add($container_sign);
}
public function closeStatement(): this {
return $this->addLine(';');
}
/**
* Start a if block, put the condition between the parenthesis, then
* it's equivalent to calling openBrace, which newline and indent.
* startIfBlock('$a === 0') generates if ($a === 0) {\n
*/
public function startIfBlock(string $condition): this {
return $this->add('if (')->add($condition)->add(')')->openBrace();
}
public function startIfBlockf(
Str\SprintfFormatString $condition,
mixed ...$args
): this {
return $this->startIfBlock(\vsprintf($condition, $args));
}
/**
* Strictly equivalent to calling closeBrace, which unindent and newline,
* but for readability, you should use this with startIfBlock
*/
public function endIfBlock(): this {
return $this->closeBrace();
}
/**
* End current if/else block, and start a 'else if (condition)' block
*/
public function addElseIfBlock(string $condition): this {
return $this
->ensureNewLine()
->unindent()
->add('} else ')
->startIfBlock($condition);
}
public function addElseIfBlockf(
Str\SprintfFormatString $condition,
mixed ...$args
): this {
return $this->addElseIfBlock(\vsprintf($condition, $args));
}
/**
* End current if/else block, and start a else block
*/
public function addElseBlock(): this {
return $this->ensureNewLine()->unindent()->add('} else')->openBrace();
}
/**
* Start a foreach loop, generate the temporary variable assignement, then
* it's equivalent to calling openBrace, which newline and indent.
*
* @param $traversable: the traversable object to iterate
* @param $key: if provided, the name of the key variable
* @param $value: the name of the value temporary variable
*
* startForeachLoop('$values', null, '$value') generates:
* foreach ($values as $value) {\n
* startForeachLoop('self::getAll()', '$arr', '$idx') generates:
* foreach (self::getAll() as $idx => $arr) {\n
*/
public function startForeachLoop(
string $traversable,
?string $key,
string $value,
): this {
$this->assertIsVariable($key !== null ? $key : '$_');
$this->assertIsVariable($value);
return $this
->addWithSuggestedLineBreaksf(
'foreach (%s as%s%s%s)',
$traversable,
self::DELIMITER,
$key !== null ? \sprintf('%s => ', $key) : '',
$value,
)
->openBrace();
}
/**
* Strictly equivalent to calling closeBrace, which unindent and newline,
* but for readability, you should use this with startForeach
*/
public function endForeachLoop(): this {
return $this->closeBrace();
}
/**
* Starts building a switch-statement that can loop over an Iterable
* to build each case-statement
*
* example:
*
* hack_builder()
* ->startSwitch('$soccer_player')
* ->addCaseBlocks(
* $players,
* ($player, $body) ==> {
* $body->addCase($player['name'])
* ->addLine('$shot = new Shot(\''.$player['favorite_shot'].'\');')
* ->returnCase('$shot->execute()');
* },
* )
* ->addDefault()
* ->addLine('invariant_violation(\'ball deflated!\');')
* ->endDefault()
* ->endSwitch();
*
*/
public function startSwitch(string $condition): this {
return $this->addLinef('switch (%s) {', $condition)->indent();
}
public function addCaseBlocks(
Traversable $switch_values,
(function(T, HackBuilder): void) $func,
): this {
foreach ($switch_values as $v) {
$func($v, $this);
}
return $this;
}
public function addCase(
T $case,
IHackBuilderValueRenderer $formatter,
): this {
return $this->addLinef('case %s:', $formatter->render($this->config, $case))
->indent();
}
public function addDefault(): this {
return $this->addLine('default:')->indent();
}
public function endDefault(): this {
return $this->unindent();
}
public function returnCase(
T $value,
IHackBuilderValueRenderer $r,
): this {
return $this->addReturn($value, $r)->unindent();
}
public function returnCasef(
Str\SprintfFormatString $value,
mixed ...$args
): this {
return
$this->returnCase(\vsprintf($value, $args), HackBuilderValues::literal());
}
public function breakCase(): this {
return $this->addLine('break;')->unindent();
}
public function endSwitch(): this {
return $this->closeBrace();
}
/**
* Start try-catch-finally blocks in the code.
* Very similar to startIfBlock, this is mostly a sugar on openBrace
* to make the code more meaningful.
*
* Example:
* hack_builder()
* ->startTryBlock()
* ->addLine('my_func();')
* ->addCatchBlock('SystemException', '$ex')
* ->addLine('return null;')
* ->addFinallyBlock()
* ->addLine('bump_ods();')
* ->endTryBlock()
*/
public function startTryBlock(): this {
return $this->add('try')->openBrace();
}
/**
* Start a catch block.
* @param $class: the class name of the exception
* @param $variable: the variable name for the exception instance
*/
public function addCatchBlock(string $class, string $variable): this {
$this->assertIsVariable($variable);
return $this
->ensureNewLine()
->unindent()
->addf('} catch (%s %s)', $class, $variable)
->openBrace();
}
/**
* Start a finally block.
*/
public function addFinallyBlock(): this {
return $this->ensureNewLine()->unindent()->add('} finally')->openBrace();
}
/**
* Strictly equivalent to calling closeBrace, which unindent and newline,
* but for readability, you should use this with startTryBlock
*/
public function endTryBlock(): this {
return $this->closeBrace();
}
/**
* Add a //-style comment
*/
public function addInlineComment(?string $comment): this {
if ($comment === null) {
return $this;
}
// Max length of each line of the docblock. Subtract 3 to compensate
// for the initial "// "
$max_length = $this->getMaxCodeLength() - 3;
$lines = $this->splitString($comment, $max_length);
foreach ($lines as $line) {
$this->addLine(\rtrim('// '.$line));
}
return $this;
}
/**
* Add a /*-style comment. You probably don't want to do this instead
* of adding a docBlock or a //-style comment, but HH_FIXME requires
* the star format soooooo here we are.
*/
public function addInlineCommentWithStars(?string $comment): this {
if ($comment === null) {
return $this;
}
// Max length of each line of the docblock. Subtract 6 to compensate
// for the initial and trailing "/* " and " */"
$max_length = $this->getMaxCodeLength() - 6;
$lines = $this->splitString($comment, $max_length);
foreach ($lines as $line) {
$this->addLine('/* '.\rtrim($line).' */');
}
return $this;
}
/**
* Add a Doc Block in the buffer. You just need to pass the text of the
* comment inside. It will take care of the indentation and splitting long
* lines. You can use line breaks in the comment.
*/
public function addDocBlock(?string $comment, ?int $max_length = null): this {
if ($comment === '' || $comment === null) {
return $this;
}
// Max length of each line of the docblock. Substract 3 to compensate
// for the initial " * "
$max_length =
$max_length !== null ? $max_length : $this->getMaxCodeLength() - 3;
$lines = $this->splitString($comment, $max_length);
$this->ensureNewLine()->addLine('/**');
foreach ($lines as $line) {
$this->addLine(\rtrim(' * '.$line));
}
$this->addLine(' */');
return $this;
}
/**
* Split a string on lines of at most $maxlen length. Line breaks in
* the string will be respected.
*/
private function splitString(
string $str,
int $maxlen,
bool $preserve_space = false,
): vec {
$lines = vec[];
$src_lines = \explode("\n", $str);
foreach ($src_lines as $src_line) {
while (Str\length($src_line) > $maxlen) {
$last_space = \strrpos(\substr($src_line, 0, $maxlen), ' ');
if ($last_space === false) {
break;
}
if ($preserve_space) {
$lines[] = \substr($src_line, 0, $last_space + 1);
} else {
$lines[] = \substr($src_line, 0, $last_space);
}
$src_line = \substr($src_line, $last_space + 1);
}
$lines[] = $src_line;
}
return $lines;
}
public static function multilineCall(
IHackCodegenConfig $config,
string $name,
Traversable $params,
bool $close_statement = false,
): string {
return (new HackBuilder($config))
->addSimpleMultilineCall($name, $params)
->addIf($close_statement, ';')
->getCode();
}
/**
* Used in static function multilineCall where we don't know the current
* indentation to wrap code correctly.
* Adds a call (method, function, array construction, etc) where each param
* is in one separate line. It's enclosed in parens. Trailing commas
* are added to the params lines.
*/
private function addSimpleMultilineCall(
string $name,
Traversable $params,
): this {
return $this
->addLine($name.'(')
->indent()
->addLines(Vec\map($params, $line ==> $line.','))
->unindent()
->add(')');
}
public function addRenderer(ICodeBuilderRenderer $renderer): this {
$renderer->appendToBuilder($this);
return $this;
}
private function assertIsVariable(string $name): void {
invariant(
Str\starts_with($name, '$'),
'Expecting a variable name, but "%s" is not valid.',
$name,
);
}
}