text: // @flow
  'use strict';
  
  const path = require('path');
  const loaders = require('./loaders');
  const readFile = require('./readFile');
  const cacheWrapper = require('./cacheWrapper');
  const getDirectory = require('./getDirectory');
  const getPropertyByPath = require('./getPropertyByPath');
  
  const MODE_SYNC = 'sync';
  
  // An object value represents a config object.
  // null represents that the loader did not find anything relevant.
  // undefined represents that the loader found something relevant
  // but it was empty.
  type LoadedFileContent = Object | null | void;
  
  class Explorer {
    loadCache: ?Map<string, Promise<CosmiconfigResult>>;
    loadSyncCache: ?Map<string, CosmiconfigResult>;
    searchCache: ?Map<string, Promise<CosmiconfigResult>>;
    searchSyncCache: ?Map<string, CosmiconfigResult>;
    config: ExplorerOptions;
  
    constructor(options: ExplorerOptions) {
      this.loadCache = options.cache ? new Map() : null;
      this.loadSyncCache = options.cache ? new Map() : null;
      this.searchCache = options.cache ? new Map() : null;
      this.searchSyncCache = options.cache ? new Map() : null;
      this.config = options;
      this.validateConfig();
    }
  
    clearLoadCache() {
      if (this.loadCache) {
        this.loadCache.clear();
      }
      if (this.loadSyncCache) {
        this.loadSyncCache.clear();
      }
    }
  
    clearSearchCache() {
      if (this.searchCache) {
        this.searchCache.clear();
      }
      if (this.searchSyncCache) {
        this.searchSyncCache.clear();
      }
    }
  
    clearCaches() {
      this.clearLoadCache();
      this.clearSearchCache();
    }
  
    validateConfig() {
      const config = this.config;
  
      config.searchPlaces.forEach(place => {
        const loaderKey = path.extname(place) || 'noExt';
        const loader = config.loaders[loaderKey];
        if (!loader) {
          throw new Error(
            `No loader specified for ${getExtensionDescription(
              place,
            )}, so searchPlaces item "${place}" is invalid`,
          );
        }
      });
    }
  
    search(searchFrom?: string): Promise<CosmiconfigResult> {
      searchFrom = searchFrom || process.cwd();
      return getDirectory(searchFrom).then(dir => {
        return this.searchFromDirectory(dir);
      });
    }
  
    searchFromDirectory(dir: string): Promise<CosmiconfigResult> {
      const absoluteDir = path.resolve(process.cwd(), dir);
      const run = () => {
        return this.searchDirectory(absoluteDir).then(result => {
          const nextDir = this.nextDirectoryToSearch(absoluteDir, result);
          if (nextDir) {
            return this.searchFromDirectory(nextDir);
          }
          return this.config.transform(result);
        });
      };
  
      if (this.searchCache) {
        return cacheWrapper(this.searchCache, absoluteDir, run);
      }
      return run();
    }
  
    searchSync(searchFrom?: string): CosmiconfigResult {
      searchFrom = searchFrom || process.cwd();
      const dir = getDirectory.sync(searchFrom);
      return this.searchFromDirectorySync(dir);
    }
  
    searchFromDirectorySync(dir: string): CosmiconfigResult {
      const absoluteDir = path.resolve(process.cwd(), dir);
      const run = () => {
        const result = this.searchDirectorySync(absoluteDir);
        const nextDir = this.nextDirectoryToSearch(absoluteDir, result);
        if (nextDir) {
          return this.searchFromDirectorySync(nextDir);
        }
        return this.config.transform(result);
      };
  
      if (this.searchSyncCache) {
        return cacheWrapper(this.searchSyncCache, absoluteDir, run);
      }
      return run();
    }
  
    searchDirectory(dir: string): Promise<CosmiconfigResult> {
      return this.config.searchPlaces.reduce((prevResultPromise, place) => {
        return prevResultPromise.then(prevResult => {
          if (this.shouldSearchStopWithResult(prevResult)) {
            return prevResult;
          }
          return this.loadSearchPlace(dir, place);
        });
      }, Promise.resolve(null));
    }
  
    searchDirectorySync(dir: string): CosmiconfigResult {
      let result = null;
      for (const place of this.config.searchPlaces) {
        result = this.loadSearchPlaceSync(dir, place);
        if (this.shouldSearchStopWithResult(result)) break;
      }
      return result;
    }
  
    shouldSearchStopWithResult(result: CosmiconfigResult): boolean {
      if (result === null) return false;
      if (result.isEmpty && this.config.ignoreEmptySearchPlaces) return false;
      return true;
    }
  
    loadSearchPlace(dir: string, place: string): Promise<CosmiconfigResult> {
      const filepath = path.join(dir, place);
      return readFile(filepath).then(content => {
        return this.createCosmiconfigResult(filepath, content);
      });
    }
  
    loadSearchPlaceSync(dir: string, place: string): CosmiconfigResult {
      const filepath = path.join(dir, place);
      const content = readFile.sync(filepath);
      return this.createCosmiconfigResultSync(filepath, content);
    }
  
    nextDirectoryToSearch(
      currentDir: string,
      currentResult: CosmiconfigResult,
    ): ?string {
      if (this.shouldSearchStopWithResult(currentResult)) {
        return null;
      }
      const nextDir = nextDirUp(currentDir);
      if (nextDir === currentDir || currentDir === this.config.stopDir) {
        return null;
      }
      return nextDir;
    }
  
    loadPackageProp(filepath: string, content: string) {
      const parsedContent = loaders.loadJson(filepath, content);
      const packagePropValue = getPropertyByPath(
        parsedContent,
        this.config.packageProp,
      );
      return packagePropValue || null;
    }
  
    getLoaderEntryForFile(filepath: string): LoaderEntry {
      if (path.basename(filepath) === 'package.json') {
        const loader = this.loadPackageProp.bind(this);
        return { sync: loader, async: loader };
      }
  
      const loaderKey = path.extname(filepath) || 'noExt';
      return this.config.loaders[loaderKey] || {};
    }
  
    getSyncLoaderForFile(filepath: string): SyncLoader {
      const entry = this.getLoaderEntryForFile(filepath);
      if (!entry.sync) {
        throw new Error(
          `No sync loader specified for ${getExtensionDescription(filepath)}`,
        );
      }
      return entry.sync;
    }
  
    getAsyncLoaderForFile(filepath: string): AsyncLoader {
      const entry = this.getLoaderEntryForFile(filepath);
      const loader = entry.async || entry.sync;
      if (!loader) {
        throw new Error(
          `No async loader specified for ${getExtensionDescription(filepath)}`,
        );
      }
      return loader;
    }
  
    loadFileContent(
      mode: 'sync' | 'async',
      filepath: string,
      content: string | null,
    ): Promise<LoadedFileContent> | LoadedFileContent {
      if (content === null) {
        return null;
      }
      if (content.trim() === '') {
        return undefined;
      }
      const loader =
        mode === MODE_SYNC
          ? this.getSyncLoaderForFile(filepath)
          : this.getAsyncLoaderForFile(filepath);
      return loader(filepath, content);
    }
  
    loadedContentToCosmiconfigResult(
      filepath: string,
      loadedContent: LoadedFileContent,
    ): CosmiconfigResult {
      if (loadedContent === null) {
        return null;
      }
      if (loadedContent === undefined) {
        return { filepath, config: undefined, isEmpty: true };
      }
      return { config: loadedContent, filepath };
    }
  
    createCosmiconfigResult(
      filepath: string,
      content: string | null,
    ): Promise<CosmiconfigResult> {
      return Promise.resolve()
        .then(() => {
          return this.loadFileContent('async', filepath, content);
        })
        .then(loaderResult => {
          return this.loadedContentToCosmiconfigResult(filepath, loaderResult);
        });
    }
  
    createCosmiconfigResultSync(
      filepath: string,
      content: string | null,
    ): CosmiconfigResult {
      const loaderResult = this.loadFileContent('sync', filepath, content);
      return this.loadedContentToCosmiconfigResult(filepath, loaderResult);
    }
  
    validateFilePath(filepath?: string) {
      if (!filepath) {
        throw new Error('load and loadSync must pass a non-empty string');
      }
    }
  
    load(filepath: string): Promise<CosmiconfigResult> {
      return Promise.resolve().then(() => {
        this.validateFilePath(filepath);
        const absoluteFilePath = path.resolve(process.cwd(), filepath);
        return cacheWrapper(this.loadCache, absoluteFilePath, () => {
          return readFile(absoluteFilePath, { throwNotFound: true })
            .then(content => {
              return this.createCosmiconfigResult(absoluteFilePath, content);
            })
            .then(this.config.transform);
        });
      });
    }
  
    loadSync(filepath: string): CosmiconfigResult {
      this.validateFilePath(filepath);
      const absoluteFilePath = path.resolve(process.cwd(), filepath);
      return cacheWrapper(this.loadSyncCache, absoluteFilePath, () => {
        const content = readFile.sync(absoluteFilePath, { throwNotFound: true });
        const result = this.createCosmiconfigResultSync(
          absoluteFilePath,
          content,
        );
        return this.config.transform(result);
      });
    }
  }
  
  module.exports = function createExplorer(options: ExplorerOptions) {
    const explorer = new Explorer(options);
  
    return {
      search: explorer.search.bind(explorer),
      searchSync: explorer.searchSync.bind(explorer),
      load: explorer.load.bind(explorer),
      loadSync: explorer.loadSync.bind(explorer),
      clearLoadCache: explorer.clearLoadCache.bind(explorer),
      clearSearchCache: explorer.clearSearchCache.bind(explorer),
      clearCaches: explorer.clearCaches.bind(explorer),
    };
  };
  
  function nextDirUp(dir: string): string {
    return path.dirname(dir);
  }
  
  function getExtensionDescription(filepath: string): string {
    const ext = path.extname(filepath);
    return ext ? `extension "${ext}"` : 'files without extensions';
  }
  