/* * 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 Facebook\DiffLib; use namespace HH\Lib\{Str, Vec}; /** * The main purposes of this class are to: * 1) Serialize a Map into a file. * Those files have the extension .codegen, they are basically used * for testing, similarly to sql3 files used by shim. * 2) Prompt the user to accept/refuse any change between an old * Map and a new one. * * We call the keys of our Map a token. It's simply * a unique identifier (usually the name of the test function) for the value * (usually the expected output of that test function). * * The main (only) user is CodegenAssertUnchanged that you can use in your * tests (see docblock). */ final class CodegenExpectedFile { const string EXTENSION = '.codegen'; const string SEPARATOR = '!@#$%codegentest:'; /** * Given a class name, return the uri where the codegen file should * be written (the uri where the class is defined -php+codegen). */ public static function getPath(string $class_name): string { $ref = new \ReflectionClass($class_name); $source_file = (string) $ref->getFileName(); // Get classname without namespace $class_name = $ref->getShortName(); return \dirname($source_file).'/'.$class_name.self::EXTENSION; } /** * Users of CodegenExpectedFile 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. */ public 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; } // // Read/Write codegen file // /** * Parse a existing codegen file, returns the Map. */ public static function parseFile(string $file_name): Map { $map = Map {}; 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 = $lines[0]; $lines = Vec\drop($lines, 1); invariant( \rtrim($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->set($token, $expected); } // Format is separator:token\n $token = \substr(\rtrim($line), \strlen(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->set($token, $expected); } return $map; } /** * Create or override a codegen file with a merge of an old * Map and a new one. * User is prompted for all mismatched values. */ public static function writeExpectedFile( string $file_name, Map $new_expected, Map $old_expected, ): void { $final_expected = Map {}; foreach ($new_expected as $token => $new_value) { if ($old_expected->contains($token)) { $old_value = $old_expected->at($token); if ($new_value === $old_value) { // No change, keep it $final_expected->set($token, $new_value); } else { // Prompt user to accept the change $update_it = self::promptForUpdate($token, $old_value, $new_value); if ($update_it === true) { $final_expected->set($token, $new_value); } else { $final_expected->set($token, $old_value); } } } else { // Prompt user to accept the new value $add_it = self::promptForAdd($token, $new_value); if ($add_it === true) { $final_expected->set($token, $new_value); } } } foreach ($old_expected as $token => $old_value) { if ($new_expected->contains($token)) { // We already made the decision continue; } // Prompt user to remove the old value $remove_it = self::promptForRemove($token, $old_value); if ($remove_it === false) { $final_expected->set($token, $old_value); } } self::writeFile($file_name, $final_expected); } /** * Create or override a codegen file with a new Map. */ public static function writeFile( string $file_name, Map $map, ): void { if (!\file_exists($file_name)) { \touch($file_name); \chmod($file_name, 0666); } $file = \fopen($file_name, 'w'); invariant( $file !== false, 'Fail to open the file %s for writing', $file_name, ); // Sorting is important or we would have merge conflict // in the generated file all the time $tokens = Vec\sort($map->keys()); \fwrite($file, '@'."generated\n"); foreach ($tokens as $token) { $value = self::escapeTokens($map->at($token)); // Format is separator:token\n \fwrite($file, self::SEPARATOR.$token."\n"); // We always add 1 newline at the end \fwrite($file, $value."\n"); } \fclose($file); } // // Stdout/Stdin helpers // /** * Helper to display any message on stdout */ public static function displayf( Str\SprintfFormatString $format, mixed ...$args ): void { $message = \vsprintf($format, $args); self::displayRaw('gentest> '.$message."\n"); } public static function displayRaw(string $message): void { \fwrite(\STDERR, $message); } private static function promptForAdd(string $token, string $value): bool { self::displayf('A new test %s was added, expected value is:', $token); self::displayRaw($value."\n"); return self::prompt('Do you accept the new expected value ?'); } private static function promptForUpdate( string $token, string $old_value, string $new_value, ): bool { self::displayf('Change of expected value for test %s:', $token); self::displayRaw( DiffLib\CLIColoredUnifiedDiff::create($old_value, $new_value), ); return self::prompt('Do you accept to update the expected value ?'); } private static function promptForRemove(string $token, string $value): bool { self::displayf('An old test %s was removed, expected value was:', $token); self::displayRaw($value."\n"); return self::prompt('Do you accept to remove the expected value ?'); } /** * Helper to ask the user for validation on stdin */ private static function prompt(string $message): bool { \stream_set_blocking(\STDIN, true); using OnExit(() ==> { \stream_set_blocking(\STDIN, false); }); while (true) { self::displayf('%s (y/N)', $message); self::displayRaw('> '); $result = \strtolower(\trim(\fgets(\STDIN))); if ($result === 'y') { return true; } if ($result === '' || $result === 'n') { return false; } } invariant_violation('For Hack'); } // // Signature helpers // /** * 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', ], ); } private static function unescapeTokens(string $s): string { return Str\replace_every( $s, dict[ '@-generated' => '@'.'generated', '@-partially-generated' => '@'.'partially-generated', ], ); } }