minitest/CodegenAssertUnchanged.hack (126 lines of code) (raw):
/*
* Copyright (c) 2004-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the hphp/hsl/ subdirectory of this source tree.
*
*/
namespace HH\__Private\MiniTest;
use namespace HH\Lib\{C, Vec, Str};
trait CodegenAssertUnchanged {
require extends HackTest;
const string EXTENSION = '.codegen';
const string SEPARATOR = '!@#$%codegentest:';
private static ?string $className = null;
private static ?dict<string, string> $expected = null;
final protected static function setupForCodegen(): void {
self::$className = static::class;
}
final protected static function tearDownForCodegen(): void {
self::$className = null;
self::$expected = null;
}
/**
* Main functionality for children classes
*
* $case determines distinct files to be generated
* $token determines sections within the generated file for the same case
*/
protected static function assertUnchanged(
string $value,
?string $token = null,
): void {
$token = $token ?? self::findToken();
$exp = self::getTokenToExpected()[$token ?? self::findToken()];
expect($exp)->toEqual(
$value,
"The value for token (%s) has changed.\nExpected: %s\nActual: %s",
$token,
$exp,
$value,
);
}
private static function getClassName(): string {
invariant(
self::$className !== null,
'setupForCodegen was not called. If you are defining setUpForRecord '.
'in your test, then you need to call self::setupForRegen() yourself.',
);
return self::$className;
}
private static function getTokenToExpected(): dict<string, string> {
if (self::$expected is null) {
self::$expected = self::parseFile(self::getPath(self::getClassName()));
}
return self::$expected;
}
/**
* Given a class name, return the uri where the codegen file should
* be written (the uri where the class is defined -php+codegen).
*/
private static function getPath(string $class_name): string {
$ref = new \ReflectionClass($class_name);
$source_file = $ref->getFileName();
// Get classname without namespace
$filename = $ref->getShortName();
return \dirname((string)$source_file).'/'.$filename.self::EXTENSION;
}
/**
* Users of this trait can use whatever token they want,
* but a common case if to use the name of the test function.
* This returns the name of the first function that starts with 'test'
* in the current stack trace.
*/
private static function findToken(): string {
$token = null;
// Get caller function name
$stack = \debug_backtrace();
foreach ($stack as $function) {
$function_name = $function['function'];
if (Str\starts_with($function_name, 'test')) {
$token = $function_name;
break;
}
}
invariant(
$token !== null,
'Test framework was unable to find a function starting with '.
'"test" when looking through the stack.',
);
return $token;
}
/**
* Parse an existing codegen file
*/
private static function parseFile(string $file_name): dict<string, string> {
$map = dict[];
if (!\file_exists($file_name)) {
return $map;
}
$lines = \file($file_name);
invariant(
$lines !== false,
'Fail to open the file %s for reading',
$file_name,
);
$generated = \array_shift(inout $lines);
invariant(
Str\trim_right((string)$generated) === '@'.'generated',
'Codegen test record file should start with a generated tag',
);
$token = null;
$expected = '';
foreach ($lines as $line) {
if (Str\starts_with($line, self::SEPARATOR)) {
if ($token !== null) {
// We always add 1 newline at the end
$expected = \substr($expected, 0, -1);
$map[$token] = $expected;
}
// Format is separator:token\n
$token = \substr(
Str\trim_right((string)$line),
Str\length(self::SEPARATOR),
);
$expected = '';
continue;
}
$expected .= self::unescapeTokens($line);
}
if ($token !== null) {
// We always add 1 newline at the end
$expected = \substr($expected, 0, -1);
$map[$token] = $expected;
}
return $map;
}
/**
* Escape the tokens that carry signatures, so that when writing those to
* the .codegen file, it doesn't seem like that's the file signature.
*/
private static function escapeTokens(string $s): string {
return Str\replace_every(
$s,
dict[
'@'.'generated' => '@-generated',
'@'.'partially-generated' => '@-partially-generated',
'@'.'nocommit' => '@-nocommit',
],
);
}
private static function unescapeTokens(string $s): string {
return Str\replace_every(
$s,
dict[
'@-generated' => '@'.'generated',
'@-partially-generated' => '@'.'partially-generated',
'@-nocommit' => '@'.'nocommit',
],
);
}
}