text: /**
   * Copyright (c) Facebook, Inc. and its affiliates.
   *
   * This source code is licensed under the MIT license found in the
   * LICENSE file in the root directory of this source tree.
   *
   * @flow strict
   * @format
   */
  
  // flowlint ambiguous-object-type:error
  
  'use strict';
  
  // flowlint nonstrict-import:warn
  import type {KeepExtraFileFn} from './CodegenRunner';
  import type {SourceControl} from './SourceControl';
  
  const Profiler = require('../core/GraphQLCompilerProfiler');
  const crypto = require('crypto');
  const invariant = require('invariant');
  const path = require('path');
  
  type Changes = {|
    +deleted: Array<string>,
    +updated: Array<string>,
    +created: Array<string>,
    +unchanged: Array<string>,
  |};
  
  export interface Filesystem {
    existsSync(path: string): boolean;
    mkdirSync(path: string): void;
    readdirSync(path: string): Array<string>;
    readFileSync(path: string, encoding: string): string;
    statSync(path: string): interface {isDirectory(): boolean};
    unlinkSync(path: string): void;
    writeFileSync(filename: string, data: string, options: string): void;
  }
  
  /**
   * CodegenDirectory is a helper class for scripts that generate code into one
   * output directory. The purpose is to make it easy to only write files that
   * have changed and delete files that are no longer generated.
   * It gives statistics about added/removed/updated/unchanged in the end.
   * The class also has an option to "validate" which means that no file
   * operations are performed and only the statistics are created for what would
   * have happened. If there's anything but "unchanged", someone probably forgot
   * to run the codegen script.
   *
   * Example:
   *
   *   const dir = new CodegenDirectory('/some/path/generated', {filesystem: require('fs')});
   *   // write files in case content changed (less watchman/mtime changes)
   *   dir.writeFile('OneFile.js', contents);
   *   dir.writeFile('OtherFile.js', contents);
   *
   *   // delete files that are not generated
   *   dir.deleteExtraFiles();
   *
   *   // arrays of file names to print or whatever
   *   dir.changes.created
   *   dir.changes.updated
   *   dir.changes.deleted
   *   dir.changes.unchanged
   */
  
  type Options = {|
    filesystem?: Filesystem,
    onlyValidate: boolean,
    shards?: number,
  |};
  
  class CodegenDirectory {
    changes: Changes;
    _filesystem: Filesystem;
    _files: Set<string>;
    _dir: string;
    onlyValidate: boolean;
    _shards: number;
  
    constructor(dir: string, options: Options) {
      this._filesystem = options.filesystem ?? require('fs');
      this.onlyValidate = options.onlyValidate;
      this._shards = options.shards ?? 1;
      if (this._filesystem.existsSync(dir)) {
        invariant(
          this._filesystem.statSync(dir).isDirectory(),
          'Expected `%s` to be a directory.',
          dir,
        );
      }
      if (!this.onlyValidate) {
        const dirs = [];
        let parent = dir;
        while (!this._filesystem.existsSync(parent)) {
          dirs.unshift(parent);
          parent = path.dirname(parent);
        }
        dirs.forEach(d => this._filesystem.mkdirSync(d));
        if (this._shards > 1) {
          for (let shard = 0; shard < this._shards; shard++) {
            const shardDir = path.join(dir, this._getShardName(shard));
            if (this._filesystem.existsSync(shardDir)) {
              invariant(
                this._filesystem.statSync(dir).isDirectory(),
                'Expected `%s` to be a directory.',
                dir,
              );
            } else {
              this._filesystem.mkdirSync(shardDir);
            }
          }
        }
      }
      this._files = new Set();
      this.changes = {
        deleted: [],
        updated: [],
        created: [],
        unchanged: [],
      };
      this._dir = dir;
    }
  
    static combineChanges(dirs: Array<CodegenDirectory>): Changes {
      const changes = {
        deleted: [],
        updated: [],
        created: [],
        unchanged: [],
      };
      dirs.forEach(dir => {
        changes.deleted.push(...dir.changes.deleted);
        changes.updated.push(...dir.changes.updated);
        changes.created.push(...dir.changes.created);
        changes.unchanged.push(...dir.changes.unchanged);
      });
      return changes;
    }
  
    static hasChanges(changes: Changes): boolean {
      return (
        changes.created.length > 0 ||
        changes.updated.length > 0 ||
        changes.deleted.length > 0
      );
    }
  
    static formatChanges(
      changes: Changes,
      options: {onlyValidate: boolean, ...},
    ): string {
      const output = [];
      function formatFiles(label, files) {
        if (files.length > 0) {
          output.push(label + ':');
          files.forEach(file => {
            output.push(' - ' + file);
          });
        }
      }
      if (options.onlyValidate) {
        formatFiles('Missing', changes.created);
        formatFiles('Out of date', changes.updated);
        formatFiles('Extra', changes.deleted);
      } else {
        formatFiles('Created', changes.created);
        formatFiles('Updated', changes.updated);
        formatFiles('Deleted', changes.deleted);
        output.push(`Unchanged: ${changes.unchanged.length} files`);
      }
  
      return output.join('\n');
    }
  
    static printChanges(
      changes: Changes,
      options: {onlyValidate: boolean, ...},
    ): void {
      Profiler.run('CodegenDirectory.printChanges', () => {
        const output = CodegenDirectory.formatChanges(changes, options);
  
        // eslint-disable-next-line no-console
        console.log(output);
      });
    }
  
    static getAddedRemovedFiles(dirs: $ReadOnlyArray<CodegenDirectory>): {|
      +added: $ReadOnlyArray<string>,
      +removed: $ReadOnlyArray<string>,
    |} {
      const added = [];
      const removed = [];
      dirs.forEach(dir => {
        dir.changes.created.forEach(name => {
          added.push(dir.getPath(name));
        });
        dir.changes.deleted.forEach(name => {
          removed.push(dir.getPath(name));
        });
      });
      return {
        added,
        removed,
      };
    }
  
    static async sourceControlAddRemove(
      sourceControl: SourceControl,
      dirs: $ReadOnlyArray<CodegenDirectory>,
    ): Promise<void> {
      const {added, removed} = CodegenDirectory.getAddedRemovedFiles(dirs);
      sourceControl.addRemove(added, removed);
    }
  
    printChanges(): void {
      CodegenDirectory.printChanges(this.changes, {
        onlyValidate: this.onlyValidate,
      });
    }
  
    read(filename: string): ?string {
      const filePath = path.join(this._dir, filename);
      if (this._filesystem.existsSync(filePath)) {
        return this._filesystem.readFileSync(filePath, 'utf8');
      }
      return null;
    }
  
    markUnchanged(filename: string) {
      this._addGenerated(filename);
      this.changes.unchanged.push(filename);
    }
  
    /**
     * Marks a files as updated or out of date without actually writing the file.
     * This is probably only be useful when doing validation without intention to
     * actually write to disk.
     */
    markUpdated(filename: string) {
      this._addGenerated(filename);
      this.changes.updated.push(filename);
    }
  
    writeFile(
      filename: string,
      content: string,
      shouldRepersist: boolean = false,
    ): void {
      Profiler.run('CodegenDirectory.writeFile', () => {
        this._addGenerated(filename);
        const filePath = this.getPath(filename);
        if (this._filesystem.existsSync(filePath)) {
          const existingContent = this._filesystem.readFileSync(filePath, 'utf8');
          if (existingContent === content && !shouldRepersist) {
            this.changes.unchanged.push(filename);
          } else {
            this._writeFile(filePath, content);
            this.changes.updated.push(filename);
          }
        } else {
          this._writeFile(filePath, content);
          this.changes.created.push(filename);
        }
      });
    }
  
    _writeFile(filePath: string, content: string): void {
      if (!this.onlyValidate) {
        this._filesystem.writeFileSync(filePath, content, 'utf8');
      }
    }
  
    /**
     * Deletes all non-generated files, except for invisible "dot" files (ie.
     * files with names starting with ".").
     */
    deleteExtraFiles(keepExtraFile?: KeepExtraFileFn): void {
      Profiler.run('CodegenDirectory.deleteExtraFiles', () => {
        if (this._shards > 1) {
          this._filesystem.readdirSync(this._dir).forEach(firstLevel => {
            if (firstLevel.startsWith('.')) {
              // allow hidden files on the first level of the codegen directory
              return;
            }
            const firstLevelPath = path.join(this._dir, firstLevel);
            if (!this._filesystem.statSync(firstLevelPath).isDirectory()) {
              // Delete all files on the top level, all files need to be in a
              // shard directory.
              this._filesystem.unlinkSync(firstLevelPath);
              return;
            }
            this._filesystem.readdirSync(firstLevelPath).forEach(actualFile => {
              if (keepExtraFile && keepExtraFile(actualFile)) {
                return;
              }
              if (this._files.has(actualFile)) {
                return;
              }
              if (!this.onlyValidate) {
                try {
                  this._filesystem.unlinkSync(
                    path.join(firstLevelPath, actualFile),
                  );
                } catch {
                  throw new Error(
                    'CodegenDirectory: Failed to delete `' +
                      actualFile +
                      '` in `' +
                      this._dir +
                      '`.',
                  );
                }
              }
              this.changes.deleted.push(actualFile);
            });
          });
        } else {
          this._filesystem.readdirSync(this._dir).forEach(actualFile => {
            if (keepExtraFile && keepExtraFile(actualFile)) {
              return;
            }
            if (actualFile.startsWith('.') || this._files.has(actualFile)) {
              return;
            }
            if (!this.onlyValidate) {
              try {
                this._filesystem.unlinkSync(path.join(this._dir, actualFile));
              } catch {
                throw new Error(
                  'CodegenDirectory: Failed to delete `' +
                    actualFile +
                    '` in `' +
                    this._dir +
                    '`.',
                );
              }
            }
            this.changes.deleted.push(actualFile);
          });
        }
      });
    }
  
    getPath(filename: string): string {
      if (this._shards > 1) {
        const hasher = crypto.createHash('md5');
        hasher.update(filename, 'utf8');
        const shard = hasher.digest().readUInt32BE(0) % this._shards;
        return path.join(this._dir, this._getShardName(shard), filename);
      }
      return path.join(this._dir, filename);
    }
  
    _getShardName(shardNumber: number): string {
      const base16length = Math.ceil(Math.log2(256) / 4);
      return shardNumber.toString(16).padStart(base16length, '0');
    }
  
    _addGenerated(filename: string): void {
      invariant(
        !this._files.has(filename),
        'CodegenDirectory: Tried to generate `%s` twice in `%s`.',
        filename,
        this._dir,
      );
      this._files.add(filename);
    }
  }
  
  module.exports = CodegenDirectory;
  