plugins_ts_dtsbundle.js

/**
 * @file src/plugins/ts/dtsbundle.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

// Object.defineProperty(exports, "__esModule", { value: true });

const os = require("os");
const { join, sep } = require("path");
const { wpwVersion } = require("../../utils/utils");
const { SpmhMessageUtils } = require("@spmhome/log-utils");
const { apply, pickNot, isFunction, isRegExp, pluralize } = require("@spmhome/type-utils");
const {
    createDirAsync,  deleteFileSync, dirname, existsSync, filename, isDirectory, isDirectoryEmpty, isFile,
    findFilesSync, findExPath, findExPathSync, forwardSlash, findFiles, readFileAsync, replaceInFile, relativePath,
    relativePathEx, resolvePath, writeFileAsync
} = require("@spmhome/cmn-utils");



// const mkdirp = require("mkdirp");
// const detectIndent = require("detect-indent");
const dtsExp = /\.d\.ts$/;
const bomOptExp = /^\uFEFF?/;
const externalExp = /^([ \t]*declare module )(['"])(.+?)(\2[ \t]*{?.*)$/;
const importExp = /^([ \t]*(?:export )?(?:import .+? )= require\()(['"])(.+?)(\2\);.*)$/;
// eslint-disable-next-line stylistic/max-len
const importEs6Exp = /^([ \t]*(?:export|import) ?(?:(?:\* (?:as [^ ,]+)?)|.*)?,? ?(?:[^ ,]+ ?,?)(?:\{(?:[^ ,]+ ?,?)*\})? ?from )(['"])([^ ,]+)(\2;.*)$/;
const referenceTagExp = /^[ \t]*\/\/\/[ \t]*<reference[ \t]+path=(["'])(.*?)\1?[ \t]*\/>.*$/;
const identifierExp = /^\w+(?:[.-]\w+)*$/;
const fileExp = /^([./].*|.:.*)$/;
const privateExp = /^[ \t]*(?:static )?private (?:static )?/;
const publicExp = /^([ \t]*)(static |)(public |)(static |)(.*)/;


class WpwTypeDeclarationsBundler
{
    /**
     * @private
     * @type {WpwBuild}
     */
    build;
    /**
     * @private
     * @type {string}
     */
    exportName;
    /**
     * @type {string[]}
     */
    externalTypings = [];
    /**
     * @type {any[]}
     */
    globalExternalImports = [];
    /**
     * @private
     * @type {string}
     */
    infoProp;
    /**
     * @private
     * @type {WpwLogger}
     */
    logger;
    /**
     * @private
     * @type {WpwPlugin}
     */
    plugin;


    /**
     * @param {WpwPlugin} plugin
     * @param {string} infoProp
     */
    constructor(plugin, infoProp)
    {
        this.plugin = plugin;
        this.build = plugin.build;
        this.logger = plugin.build.logger;
        this.infoProp = infoProp || plugin.optionsKey;
    }


    /**
     * @param {WpwTypesDtsBundleOptions} options
     * @returns {WpwTypesDtsBundleOptions}
     */
    applyOptions(options)
    {
        const _baseDir = (() => {
            const baseDir = this.optValue(options.baseDir, dirname(options.main));
            return this.allFiles && !options.baseDir ? baseDir.substr(0, baseDir.length - 2) : baseDir;
        })();
        this.exportName = options.name;
        this.globalExternalImports = [];
        this.main = this.allFiles ? "*.d.ts" : options.main;
        this.out = this.optValue(options.out, this.exportName + ".d.ts").replace(/\//g, sep);
        this.newline = this.optValue(options.newline, os.EOL);
        this.indent = this.optValue(options.indent, "    ");
        this.outputAsModuleFolder = this.optValue(options.outputAsModuleFolder, false);
        this.prefix = this.optValue(options.prefix, "");
        this.separator = this.optValue(options.separator, "/");
        this.externals = this.optValue(options.externals, false);
        this.exclude = this.optValue(options.exclude, null);
        this.removeSource = this.optValue(options.removeSource, false);
        this.referenceExternals = this.optValue(options.referenceExternals, false);
        this.emitOnIncludedFileNotFound = this.optValue(options.emitOnIncludedFileNotFound, false);
        this.emitOnNoIncludedFileNotFound = this.optValue(options.emitOnNoIncludedFileNotFound, false);
        this._headerPath = this.optValue(options.headerPath, null);
        this.headerText = this.optValue(options.headerText, "");
        this.comments = false;
        this.allFiles = this.stringEndsWith(options.main, "**/*.d.ts");
        this.verbose = this.optValue(options.verbose, false);
        this.baseDir = resolvePath(_baseDir);
        this.mainFile = this.allFiles ? resolvePath(this.baseDir, "**/*.d.ts") :
                                        resolvePath(this.main.replace(/\//g, sep));
        this.outFile = this.calcOutFilePath(this.out, this.baseDir);
        this.headerData = "// generated by webpack-wrap v" + wpwVersion() + this.newline;
        this.headerPath = this._headerPath && this._headerPath !== "none" ?
                        resolvePath(this._headerPath.replace(/\//g, sep)) : this._headerPath;
        return options;
    }


    /**
    * @param {string} [pad]
    * @returns {Promise<Exclude<WpwPluginTaskResult, boolean>>} absolute path to type declarations bundle
    * @throws {WpwError}
    */
    async bundle(pad = "   ")
    {
        /** @type {Exclude<WpwPluginTaskResult, boolean>} */
        let result;
        const l = this.logger.write("bundle type declarations", 1, pad),
              params = this.getParams();
        try
        {   const opts = this.applyOptions(await this.getOptions(params, pad + "   "));
            if (!opts){
                throw (this.build.lastError || new Error("unable to create type declarations bundle"));
            }
            this.sourceTypings = (await findFiles("**/*.d.ts", { cwd: this.baseDir }))
                                        .map((file) => resolvePath(this.baseDir, file));
            result = await this._bundle(opts, pad + "   ");
            // await replaceInFile(params.outFileAbs, opts.name, this.build.pkgJson.name);
            await replaceInFile(result.paths[0], opts.name, this.build.pkgJson.name);
            result.srcpaths = this.sourceTypings;
        }
        catch (e)
        {   throw SpmhMessageUtils.isSpmh(e) ? e : this.plugin.addMessage({
                exception: e,
                code: SpmhMessageUtils.Code.ERROR_TYPES_BUNDLE_FAILED,
                message: "unable to create type declarations bundle"
            }, true);
        }
        l.ok("bundle type declarations", 1, pad);
        return result;
    }


    /**
     * @param {WpwTypesDtsBundleOptions} options
    * @param {string} [pad]
     * @returns {Promise<Exclude<WpwPluginTaskResult, boolean>>}
     */
    async _bundle(options, pad)
    {
        /*
        assert(typeof options === "object" && options, "options must be an object");
        const main = this.allFiles ? "*.d.ts" : options.main;

        assert.ok(main, 'option "main" must be defined');
        assert.ok(this.exportName, 'option "name" must be defined');
        assert(typeof this.newline === "string", 'option "newline" must be a string');
        assert(typeof this.indent === "string", 'option "indent" must be a string');
        assert(typeof this.prefix === "string", 'option "prefix" must be a string');
        assert(this.separator.length > 0, 'option "separator" must have non-zero length');
        */
        this.logger.write("begin constructing bundle file content", 1, pad);

        if (!this.allFiles) {
            // assert(existsSync(this.mainFile), "   main does not exist: " + this.mainFile);
        }
        if (this.headerPath)
        {   if (this.headerPath === "none") {
                this.headerData = "";
            }
            else {
                // assert(existsSync(this.headerPath), "header does not exist: " + this.headerPath);
                this.headerData = await readFileAsync(this.headerPath) + this.headerData;
            }
        }
        else if (this.headerText)
        {
            this.headerData = "/*" + this.headerText + "*/\n";
        }

        let isExclude;
        if (isFunction(this.exclude)) {
            isExclude = this.exclude;
        }
        else if (isRegExp(this.exclude)) {
            isExclude = (file) => this.exclude.test(file);
        }
        else { isExclude = () => { return false; }; }

        if (this.allFiles)
        {
            let mainFileContent_1 = "";
            this.logger.write("create temporally main file", 1, pad);
            this.sourceTypings.forEach((file) =>
            {
                const generatedLine = "export * from './" +
                    relativePath(this.baseDir, file.substring(0, file.length - 5)).replace(sep, "/") + "';";
                this.logger.write(generatedLine, 4, pad);
                mainFileContent_1 += generatedLine + "\n";
            });
            this.mainFile = resolvePath(this.baseDir, "dts-bundle.tmp." + this.exportName + ".d.ts");
            await writeFileAsync(this.mainFile, mainFileContent_1);
        }

        this.logger.write("find typings", 1, pad);
        this.logger.write("   source typings (will be included in output if actually used)", 4, pad);
        this.sourceTypings.forEach((file) => this.logger.write(file, 4, pad + "   "));

        this.logger.write("   excluded typings (will always be excluded from output)", 4, pad);
        this.sourceTypings.forEach((file) => this.logger.write(file, 4, pad + "   "));

        const fileMap = Object.create(null),
              queue = [ this.mainFile ];
        let mainParse,
            queueSeen = Object.create(null);

        this.logger.write(`parse ${queue.length} main ${pluralize("file", queue.length)}`, 1, pad);
        while (queue.length > 0)
        {
            const target = queue.shift();
            if (queueSeen[target]) {
                continue;
            }
            queueSeen[target] = true;
            const parse = await this.parseFile(target, pad + "   ");
            if (!mainParse) {
                mainParse = parse;
            }
            fileMap[parse.file] = parse;
            this.pushUniqueArr(queue, parse.refs, parse.relativeImports);
        }
        this.logger.write(`parsed ${queue.length} asset ${pluralize("path", queue.length)}`, 4, pad);

        const exportMap = Object.create(null),
              fileMapKeys = Object.keys(fileMap);
        if (fileMapKeys.length > 0)
        {
            this.logger.write(`map ${fileMapKeys.length} ${pluralize("export", fileMapKeys.length)}`, 1, pad);
            Object.keys(fileMap).forEach((file) =>
            {
                const parse = fileMap[file];
                parse.exports.forEach((name) =>
                {
                    // assert(!(name in exportMap), "   already processed export for " + name);
                    exportMap[name] = parse;
                    this.logger.value(`   export ${name}`, parse.file, 4, pad);
                });
            });
        }

        const excludedTypings = [],
              usedTypings = [],
              externalDependencies = [],
              queue_1 = [ mainParse ];
        queueSeen = Object.create(null);
        this.logger.write("dump queue:", 4, pad);
        this.logger.write(queue_1, 4, pad + "   ");

        this.logger.write("determine imported / included typings", 1, pad);
        while (queue_1.length > 0)
        {
            const parse = queue_1.shift();
            if (queueSeen[parse.file]) {
                continue;
            }
            queueSeen[parse.file] = true;
            usedTypings.push(parse);
            let aLen = parse.externalImports.length;
            if (aLen > 0)
            {
                this.logger.write(`   process ${aLen} external ${pluralize("import", aLen)}`, 3, pad);
                parse.externalImports.forEach((name) =>
                {
                    const p = exportMap[name];
                    if (!this.externals)
                    {
                        this.logger.value("      exclude external", name, 3, pad);
                        this.pushUnique(externalDependencies, !p ? name : p.file);
                        return;
                    }
                    if (isExclude(relativePath(this.baseDir, p.file)))
                    {
                        this.logger.value("      exclude external filter", name, 3, pad);
                        this.pushUnique(excludedTypings, p.file);
                        return;
                    }
                    this.logger.value("      include external", name, 4, pad);
                    // assert(p, name);
                    queue_1.push(p);
                });
            }
            aLen = parse.relativeImports.length;
            if (aLen > 0)
            {
                this.logger.write(`   process ${aLen} relative ${pluralize("import", aLen)}`, 3, pad);
                parse.relativeImports.forEach((file) =>
                {
                    const p = fileMap[file];
                    if (isExclude(relativePath(this.baseDir, p.file)))
                    {
                        this.logger.value("      excluded by internal filter", file, 3, pad);
                        this.pushUnique(excludedTypings, p.file);
                        return;
                    }
                    this.logger.value("      import relative file", file, 5, pad);
                    // assert(p, file);
                    queue_1.push(p);
                });
            }
        }

        if (usedTypings.length > 0)
        {
            this.logger.write("rewrite global external modules", 1, pad);
            usedTypings.forEach((parse) =>
            {
                parse.relativeRef.forEach((line) =>
                {
                    line.modified = this.replaceExternal(line.original, this.getLibName);
                    this.logger.write(`   transform ~ ${line.original} ==>`, 5, pad);
                    this.logger.write(`               ${line.modified}`, 5, pad);
                });
                parse.importLineRef.forEach((line) =>
                {   if (this.outputAsModuleFolder)
                    {
                        this.logger.write(`   line '${line.original}' was skipped`, 5, pad);
                        line.skip = true;
                        return;
                    }
                    if (importExp.test(line.original)) {
                        line.modified = this.replaceImportExport(line.original, this.getLibName);
                    }
                    else {
                        line.modified = this.replaceImportExportEs6(line.original, this.getLibName);
                    }
                    this.logger.write("   line transform applied", 5, pad);
                    this.logger.value("      original line", line.original, 5, pad);
                    this.logger.value("      modified line", line.modified, 5, pad);
                });
            });
            this.logger.write("completed rewrite of global external modules", 4, pad);
        }

        this.logger.write("build output", 1, pad);
        let content = this.headerData;

        if (externalDependencies.length > 0)
        {
            content += "// Dependencies for this module:" + this.newline;
            externalDependencies.forEach((file) =>
            {   if (this.referenceExternals) {
                    content += this.formatReference(relativePath(this.baseDir, file).replace(/\\/g, "/")) + this.newline;
                }
                else {
                    content += "//   " + relativePath(this.baseDir, file).replace(/\\/g, "/") + this.newline;
                }
            });
        }
        if (this.globalExternalImports.length > 0) {
            content += `${this.newline}${this.globalExternalImports.join(this.newline) + this.newline}`;
        }

        content += this.newline;
        content += usedTypings.filter((parse) =>
        {
            parse.lines = parse.lines.filter((line) => line.skip !== true);
            return (parse.lines.length > 0);
        })
        .map((parse) =>
        {
            if (this.inSourceTypings(parse.file))
            {   return this.formatModule(
                    parse.file,
                    parse.lines.map((line) => this.getIndenter(parse.indent, this.indent)(line))
                );
            }
            return parse.lines.map((line) => this.getIndenter(parse.indent, this.indent)(line))
                              .join(this.newline) + this.newline;
        })
        .join(this.newline) + this.newline;

        if (this.removeSource)
        {
            this.logger.write("   remove source type declaration files", 2, pad);
            this.sourceTypings
                .filter((p) => p !== this.outFile && dtsExp.test(p) && isFile(p))
                .forEach((p) => {
                    this.logger.write("    " + p, 4, pad);
                    deleteFileSync(p); }
                );
        }

        const inUsed = (file) => usedTypings.filter((parse) => parse.file === file).length !== 0;

        const notFound = Object.values(fileMap).filter((p) => !p.fileExists);
        if (notFound.length > 0)
        {
            this.logger.write(`   ${notFound.length} files were not found:`, 1, pad);
            notFound.forEach((parse, i) =>
            {
                if (inUsed(parse.file))
                {
                    this.logger.error(`      (${i - 1}) ${parse.file} [used][fatal]`, pad);
                    this.plugin.addMessage({
                        code: SpmhMessageUtils.Code.ERROR_RESOURCE_MISSING,
                        message: `unable to read included declarations file ${parse.file} [not found]`
                    });
                }
                else {
                    this.logger.warn(`      (${i - 1})${parse.file} [not_used][non_fatal]`, pad);
                    this.plugin.addMessage({
                        code: SpmhMessageUtils.Code.WARNING_RESOURCE_MISSING,
                        message: `unable to read un-included declarations file ${parse.file} [not found]`
                    });
                }
            });
        }

        if ((this.build.errorCount === 0 || this.emitOnIncludedFileNotFound) &&
            (this.build.warnings.length === 0 || this.emitOnNoIncludedFileNotFound))
        {
            const outDir = dirname(this.outFile);
            this.logger.write(`   persist bundle content '${filename(this.outFile)}'`, 2, pad);
            this.logger.value("      directory", outDir, 2, pad);
            if (!existsSync(outDir)) {
                await createDirAsync(outDir);
            }
            await writeFileAsync(this.outFile, content);
        }
        else
        {   return void this.plugin.addMessage({
                code: SpmhMessageUtils.Code.ERROR_RESOURCE_MISSING,
                message: `could not find ${notFound.length} included files [included::${this.build.errorCount}]` +
                         `[not_included::[${this.build.warnings.length}]]`,
                suggest: "if unable to resolve the issue, try setting the ignoreIncludedNotFound and / " +
                         "ignoreNotIncludedFileNotFound options"
            });
        }

        if (this.logger.level >= 3)
        {
            this.logger.write("   used source typings:", 4, pad);
            this.sourceTypings.filter((p) => inUsed(p)).forEach((p) => this.logger.write(p, 4, "      "));
            this.logger.write("   unused source typings:", 4, pad);
            this.sourceTypings.filter((p) => !inUsed(p)).forEach((p) => this.logger.write(p, 4, "      "));
            this.logger.write("   excluded typings:", 4, pad);
            excludedTypings.forEach((p) => this.logger.write(p, 3, "      "));
            this.logger.write("   used external typings:", 4, pad);
            this.externalTypings.filter((p) => inUsed(p)).forEach((p) => this.logger.write(p, 4, "      "));
            this.logger.write("   unused external typings:", 4, pad);
            this.externalTypings.filter((p) => !inUsed(p)).forEach((p) => this.logger.write(p, 4, "      "));
            this.logger.write("   external dependencies:", 4, pad);
            externalDependencies.forEach((p) => this.logger.write(p, 4, "      "));
        }

        const bundleEmitPath = join(this.build.getDistPath(), relativePath(this.main, this.outFile));
        this.logger.ok(`created type declarations bundle @ '${bundleEmitPath}'`, 1, pad);
        if (this.allFiles) {
            deleteFileSync(this.mainFile);
        }
        return {
            glob: "*.d.ts", immutable: false, paths: [ this.outFile ], srcpaths: this.sourceTypings, tag: this.infoProp
        };
    }


    /**
     * @private
     * @param {WpwBuild} build
     * @param {WebpackCompilation} compilation
     * @param {string} [outputDir] abs path
     * @returns {string}
     */
    getDtsEntryFile(build, compilation, outputDir)
    {
        const entryOptions = /** @type {WebpackEntryOptions} */(compilation.entries.get(build.name)?.options),
              entryName = /** @type {string} */(entryOptions.name),
              extRgx = new RegExp(`\\.(?:${build.source.ext}|[cm]?[jt]s|d\\.ts)`);
        outputDir = resolvePath(outputDir || compilation.compiler.outputPath);
        let dtsEntryFile = join(outputDir, `${entryName}.${build.source.ext.replace(extRgx, ".d.ts")}`);
        if (!existsSync(dtsEntryFile))
        {
            dtsEntryFile = findExPathSync([
                `${build.type}.d.ts")}`, `${build.name}.d.ts`, "index.d.ts", "types.d.ts"
            ], outputDir, true);
            if (!dtsEntryFile || !existsSync(dtsEntryFile))
            {
                const assetName = /** @type {string} */(compilation.getAsset(entryName)?.name);
                if (assetName)
                {
                    dtsEntryFile = resolvePath(outputDir, assetName.replace(extRgx, "d.ts"));
                    if (!existsSync(dtsEntryFile) && existsSync(join(outputDir, "bin")))
                    {
                        dtsEntryFile = findFilesSync(
                            "**/*{index,types,typings}.d.ts", { cwd: join(outputDir, "bin"), maxDepth: 3 }
                        )[0];
                    }
                }
            }
        }
        if (!dtsEntryFile || !existsSync(dtsEntryFile))
        {
            build.addMessage({
                code: SpmhMessageUtils.Code.ERROR_TYPES_FAILED,
                message: "types build: failed to find entry file for bundle"
            });
            return "index.d.ts";
        }
        return relativePathEx(outputDir, dtsEntryFile, { psx: true });
    }


    /**
     * @private
     * @param {WpwTypesBundleParams} params
     * @param {string} [pad]
     * @returns {Promise<WpwTypesDtsBundleOptions>}
     */
    async getOptions(params, pad = "   ")
    {
        const l = this.logger;
        let main = params.bundleOptions.main;
        const out = params.outFileAbs,
              baseDir = params.baseDir,
              verbose =  this.build.log.level >= 4 || !!params.bundleOptions.verbose,
              name = `${this.build.pkgJson.name}-${this.build.name}`.replace(/\//g, "-").replace(/@/g, "");

        l.write("construct 'dts-bundle' options object", 1, pad);
        l.value("   is file mode", params.isFileMode, 1, pad);
        l.value("   base directory", params.baseDir, 1, pad);
        l.value("   temporary namespace", name, 1, pad);
        l.write("   validate entry point specified by option 'main'", 1, pad);
        l.value("      rc-configured current value", main, 1, pad);

        if (main)
        {
            if (main === "entry" || params.isFileMode)
            {
                main = this.getDtsEntryFile(this.build, this.plugin.compilation, params.dtsBuildDir);
            }
            else if (main !== "entry" && !params.isFileMode && isDirectory(main))
            {
                main = main.replace(/\/\*[*/]+$/, "");
            }
            if (!main || !existsSync(resolvePath(params.baseDirProj,  main)))
            {
                l.write("  option 'main' could not be validated using the rc-configured value", 1, pad);
            }
        }

        if (!main || (!main.endsWith("*.d.ts") && !existsSync(resolvePath(params.baseDirProj, main))))
        {
            l.write("  attempt to auto-populate option value", 1, pad);
            if (params.isFileMode)
            {
                main = await findExPath([ "index.d.ts", "types.d.ts" ], params.dtsBuildDir, true);
            }
            if (!main && !(await isDirectoryEmpty(params.dtsBuildDir)))
            {
                l.write(`   check v_build directory '${params.dtsBuildDir}' for emptiness`, 1, pad);
                l.write("     not empty, set option 'main' to temp to v_build directory", 1, pad);
                if (params.isFileMode)
                {
                    params.isFileMode = false;
                    this.plugin.addMessage({
                        code: SpmhMessageUtils.Code.WARNING_OPTIONS_INVALID,
                        message: "auto-switching to 'directory' mode with option 'directoryRecurse' set",
                        detail: "switching to 'directory' mode was done because the build directory was not empty, it is " +
                                "however recommended to fix the issue in the wpwrc configuration file",
                        suggest: "specify a valid path to the input entry point file with rc option 'types.bundle.main', " +
                                 "or try 'directory' mode with/without the 'directoryRecurse' options set"
                    });
                }
                main = params.dtsBuildDir;
            }
        }

        if (!main || (!main.endsWith("*.d.ts") && !existsSync(resolvePath(params.baseDirProj, main))))
        {
            l.write("  option 'main' could not be auto-configured [step_3]", 1, pad);
            l.value("      final attempted value (raw)", main, 1, pad);
            l.value("      final attempted value (rtesolved)", resolvePath(params.baseDirProj, main), 1, pad);
            l.write("exhausted all auto-configurable possibilities, unable to proceed", 1, pad);
            return void this.plugin.addMessage({
                code: SpmhMessageUtils.Code.ERROR_TYPES_BUNDLE_FAILED,
                message: "unable to locate an entry file",
                suggest: "specify a valid path to the input entry point file with rc option 'types.bundle.main', " +
                         "or try 'directory' mode with/without the 'directoryRecurse' options set"
            });
        }

        if (!params.isFileMode)
        {
            l.write("  directory mode enabled, apply applicable glob pattern", 1, pad);
            l.value("      is recursive mode", params.bundleOptions.mode, 1, pad);
            main = forwardSlash(main).replace(/\/\*[*/]+$/, "") +
                   (params.bundleOptions.mode === "directoryRecurse" ? "/**/*.d.ts" : "/*.d.ts");
        }

        l.ok("constructed 'dts-bundle' options object", 1, pad);
        return apply(
            apply({ verbose, outputAsModuleFolder: false }, pickNot(params.bundleOptions, "enabled")),
            { main, name, baseDir, out }
        );
    };


    /**
     * @returns {WpwTypesBundleParams}
     */
    getParams()
    {
        const build = this.build,
              baseDirProj = build.getBasePath(),
              distDirProj = build.getDistPath(),
              baseDistDir = build.getDistPath({ build: "app "}),
              distDirRel = relativePath(baseDistDir, distDirProj),
              dtsDistDir = build.virtualEntry.dirDist,
              dtsBuildDir = build.virtualEntry.dirBuild,
              bundleOptions = /** @type {IWpwPluginConfigTypesBundle} */(build.options.types?.bundle || {}),
              dtsBundleFilename = bundleOptions.out || join(distDirRel, build.name + ".d.ts"),
              outFileAbs = join(dtsDistDir, distDirRel, dtsBundleFilename),
              distFileAbs = join(distDirProj, distDirRel, dtsBundleFilename);
        return {
            build, baseDir: dtsBuildDir, distDir: dtsDistDir,
            distDirProj, baseDirProj, baseDistDir,
            distDirRel, dtsDistDir, dtsBuildDir, outFileAbs,
            bundleOptions, dtsBundleFilename, distFileAbs,
            dtsOutDirRelToBuild: relativePath(dtsBuildDir, dtsDistDir),
            dtsDistDirRelToProj: relativePath(baseDirProj, distDirProj),
            dtsBuildDirRelToProj: relativePath(baseDirProj, dtsBuildDir),
            outFileRelToBuild: relativePath(dtsBuildDir, outFileAbs),
            distFileRelToProj: relativePath(baseDirProj, distFileAbs),
            distFileRel: relativePath(distDirProj, distFileAbs),
            isFileMode: bundleOptions.mode === "file"
        };
    };


    /**
     * @param {WpwTypesDtsBundleOptions} opts
     * @param {*} params
     * @param {string} pad
     * @returns {WpwTypesDtsBundleOptions}
     */
    printOptions(opts, params, pad)
    {
        const l = this.logger;
        l.object(params, "finalized input/output parameters", "params", 2, pad + "   ");
        l.write("finalized options:", 1, pad);
        l.value("   export name", this.exportName, 1, pad);
        l.value("   comments", this.comments ? "yes" : "no", 1, pad);
        l.value("   transformed mainFile", this.mainFile, 1, pad);
        l.value("   transformed outFile", this.outFile, 1, pad);
        l.object(opts, null, "options", 1, pad + "   ");
        return opts;
    }


    /**
     * @param {Buffer} data
     * @param {WpwTypesDtsBundleOptions} cfg
     * @param {WpwBuild} build
     * @returns {Buffer}
     */
    transform(data, cfg, build)
    {
        return Buffer.from(data.toString().replace(new RegExp(cfg.name, "g"), build.pkgJson.name));
    }


    /**
     * @private
     * @param {string} file
     * @returns {boolean}
     */
    inExternalTypings = (file) => { return this.externalTypings.includes(file); };


    inSourceTypings(file)
    {
        return this.sourceTypings.includes(file) || this.sourceTypings.includes(join(file, "index.d.ts"));
    }


    stringEndsWith(str, suffix)
    {
        return str.indexOf(suffix, str.length - suffix.length);
    }


    stringStartsWith(str, prefix)
    {
        return str.slice(0, prefix.length) === prefix;
    }


    calcOutFilePath(out, baseDir)
    {
        return !this.stringStartsWith(out, "~" + sep) ? resolvePath(baseDir, out) : resolvePath(".", out.substr(2));
    }


    getModName(file)
    {
        return relativePath(this.baseDir, dirname(file) + sep + filename(file).replace(/\.d\.ts$/, ""));
    }


    getExpName(file)
    {
        return file === this.mainFile ?  this.exportName : this.getExpNameRaw(file);
    }


    getExpNameRaw(file)
    {
        return this.prefix + this.exportName + this.separator + this.cleanupName(this.getModName(file));
    }


    getLibName(ref)
    {
        return this.getExpNameRaw(this.mainFile) + this.separator + this.prefix + this.separator + ref;
    }


    cleanupName(name)
    {
        return name.replace(/\.\./g, "--").replace(/[\\/]/g, this.separator);
    }


    mergeModulesLines(lines)
    {
        const i = (this.outputAsModuleFolder ? "" : this.indent);
        return (lines.length === 0 ? "" : i + lines.join(this.newline + i)) + this.newline;
    }


    formatModule(file, lines)
    {
        let out = "";
        if (this.outputAsModuleFolder) {
            return this.mergeModulesLines(lines);
        }
        out += "declare module '" + this.getExpName(file) + "' {" + this.newline;
        out += this.mergeModulesLines(lines);
        out += "}" + this.newline;
        return out;
    }


    async parseFile(file, pad)
    {
        const name = this.getModName(file);
        this.logger.value("parse " + name, file, 4, pad);
        const res = {
            file, name, indent: this.indent, exp: this.getExpName(file),
            refs: [], externalImports: [], relativeImports: [], exports: [],
            lines: [], fileExists: true, importLineRef: [], relativeRef: []
        };
        if (!existsSync(file))
        {
            this.logger.warn(`   unable to read file '${file}' [not found]`, 1);
            res.fileExists = false;
            return res;
        }
        if (isDirectory(file)) {
            file = join(file, "index.d.ts");
        }

        const code = (await readFileAsync(file)).replace(bomOptExp, "").replace(/\s*$/, "");
        // res.indent = detectIndent(code) || indent;
        let multiComment = [],
            queuedJSDoc,
            inBlockComment = false;

        const popBlock = () =>
        {   if (multiComment.length > 0)
            {   if (/^[ \t]*\/\*\*/.test(multiComment[0]))
                {
                    queuedJSDoc = multiComment;
                }
                else if (this.comments) {
                    multiComment.forEach((line) => res.lines.push({ original: line }));
                }
                multiComment = [];
            }
            inBlockComment = false;
        };

        const popJSDoc = () =>
        {   if (queuedJSDoc)
            {   queuedJSDoc.forEach((line) =>
                {   const match = line.match(/^([ \t]*)(\*.*)/);
                    if (match) {
                        res.lines.push({ original: match[1] + " " + match[2] });
                    }
                    else {
                        res.lines.push({ original: line });
                    }
                });
                queuedJSDoc = null;
            }
        };

        code.split(/\r?\n/g).forEach((line) =>
        {
            let match;
            if (/^[((=====)(=*)) \t]*\*+\//.test(line))
            {
                multiComment.push(line);
                popBlock();
                return;
            }
            if (/^[ \t]*\/\*/.test(line))
            {
                multiComment.push(line);
                inBlockComment = true;
                if (/\*+\/[ \t]*$/.test(line)) {
                    popBlock();
                }
                return;
            }
            if (inBlockComment)
            {
                multiComment.push(line);
                return;
            }
            if (/^\s*$/.test(line))
            {
                res.lines.push({ original: "" });
                return;
            }

            if (/^\/\/\//.test(line))
            {
                const ref = this.extractReference(line);
                if (ref)
                {
                    const refPath = resolvePath(dirname(file), ref);
                    if (this.inSourceTypings(refPath))
                    {
                        this.logger.write(`   reference source typing ${ref} (${refPath})`, 2, pad);
                    }
                    else
                    {   const relPath = relativePath(this.baseDir, refPath).replace(/\\/g, "/");
                        this.logger.write(`   reference external typing ${ref} (${refPath}) (relative: ${relPath})`, 2, pad);
                        if (!this.inExternalTypings(refPath)) {
                            this.externalTypings.push(refPath);
                        }
                    }
                    this.pushUnique(res.refs, refPath);
                    return;
                }
            }

            if (/^\/\//.test(line))
            {
                if (this.comments) {
                    res.lines.push({ original: line });
                }
                return;
            }
            if (privateExp.test(line))
            {
                queuedJSDoc = null;
                return;
            }

            popJSDoc();

            if ((line.indexOf("from") >= 0 && (match = line.match(importEs6Exp))) ||
                (line.indexOf("require") >= 0 && (match = line.match(importExp))))
            {
                const // _ = match[0],
                      lead = match[1], quote = match[2], moduleName = match[3], trail = match[4];
                // assert(moduleName);
                const impPath = resolvePath(dirname(file), moduleName);
                if (fileExp.test(moduleName))
                {
                    const modLine = { original: lead + quote + this.getExpName(impPath) + trail };
                    res.lines.push(modLine);
                    let full = resolvePath(dirname(file), impPath);
                    if (!existsSync(full) || existsSync(full + ".d.ts")) {
                        full += ".d.ts";
                    }
                    this.logger.write("   import relative " + moduleName +  "(" + full + ")", 5, pad);
                    this.pushUnique(res.relativeImports, full);
                    res.importLineRef.push(modLine);
                }
                else
                {   const modLine = { original: line };
                    this.logger.write("   import external " + moduleName, 3, pad);
                    this.pushUnique(res.externalImports, moduleName);
                    if (this.externals) {
                        res.importLineRef.push(modLine);
                    }
                    if (!this.outputAsModuleFolder) {
                        res.lines.push(modLine);
                    }
                    else {
                        this.pushUnique(this.globalExternalImports, line);
                    }
                }
            }
            else if ((match = line.match(externalExp)) !== null)
            {
                const // _ = match[0],
                      // declareModule = match[1],
                      // lead = match[2],
                      moduleName = match[3];
                      // trail = match[4];
                // assert(moduleName);
                this.logger.write("   declare " + moduleName, 1, pad);
                this.pushUnique(res.exports, moduleName);
                const modLine = { original: line };
                res.relativeRef.push(modLine);
                res.lines.push(modLine);
            }
            else
            {   if ((match = line.match(publicExp)) !== null)
                {
                    const // _ = match[0],
                          sp = match[1], static1 = match[2],
                          // pub = match[3],
                          static2 = match[4], ident = match[5];
                    line = sp + static1 + static2 + ident;
                }
                if (this.inSourceTypings(file))
                {
                    res.lines.push({ original: line.replace(/^(export )?declare /g, "$1") });
                }
                else {
                    res.lines.push({ original: line });
                }
            }
        });

        return res;
    }


    pushUnique(arr, value)
    {
        if (arr.indexOf(value) < 0) {
            arr.push(value);
        }
        return arr;
    }


    pushUniqueArr(arr)
    {
        const values = [];
        for (let _i = 1; _i < arguments.length; _i++) {
            values[_i - 1] = arguments[_i];
        }
        values.forEach((vs) => vs.forEach((v) => this.pushUnique(arr, v)));
        return arr;
    }


    /**
     * @param {string} file
     * @returns {string}
     */
    formatReference(file) { return '/// <reference path="' + file.replace(/\\/g, "/") + '" />'; }


    extractReference(tag)
    {
        const match = tag.match(referenceTagExp);
        return match ? match[2] : null;
    }


    replaceImportExport(line, replacer)
    {
        const match = line.match(importExp);
        if (match)
        {   // assert(match[4]);
            if (identifierExp.test(match[3])) {
                return match[1] + match[2] + replacer(match[3]) + match[4];
            }
        }
        return line;
    }


    replaceImportExportEs6(line, replacer)
    {
        if (line.indexOf("from") < 0) {
            return line;
        }
        const match = line.match(importEs6Exp);
        if (match)
        {   // assert(match[4]);
            if (identifierExp.test(match[3])) {
                return match[1] + match[2] + replacer(match[3]) + match[4];
            }
        }
        return line;
    }


    replaceExternal(line, replacer)
    {
        const match = line.match(externalExp);
        if (match)
        {   const // _ = match[0],
                  declareModule = match[1], beforeIndent = match[2],
                  moduleName = match[3],  afterIdent = match[4];
            // assert(afterIdent);
            if (identifierExp.test(moduleName)) {
                return declareModule + beforeIndent + replacer(moduleName) + afterIdent;
            }
        }
        return line;
    }


    getIndenter(actual, use)
    {
        if (actual === use || !actual)
        {
            return (line) => line.modified || line.original;
        }
        return ((line) => (
            line.modified || line.original).replace(new RegExp("^" + actual + "+", "g"),
            (match) => match.split(actual).join(use)
        ));
    }


    optValue(passed, def)
    {
        return typeof passed === "undefined" ? def : passed;
    }
}


module.exports = WpwTypeDeclarationsBundler;