├── .eslintignore ├── test └── data │ ├── complex │ ├── zero.h │ ├── second.hpp │ ├── include │ │ └── third.hpp │ ├── first.hpp │ ├── main.cpp │ ├── zero.c │ ├── first.cpp │ ├── second.cpp │ └── expected.cpp │ ├── sourceInSubdirectories │ ├── second.hpp │ ├── second.cpp │ ├── include │ │ └── one │ │ │ └── two │ │ │ └── third.hpp │ ├── one │ │ └── two │ │ │ └── three │ │ │ ├── first.hpp │ │ │ └── first.cpp │ ├── src │ │ └── one │ │ │ └── two │ │ │ └── third.cpp │ ├── main.cpp │ └── expected.cpp │ ├── headerIncludeHeaderInSubdirectory │ ├── main.cpp │ ├── include │ │ └── b.hpp │ ├── a.hpp │ └── expected.cpp │ ├── circularDependencies │ ├── second.hpp │ ├── main.cpp │ ├── expected.cpp │ └── first.hpp │ ├── missingInclude │ └── main.cpp │ ├── parse │ ├── header.hpp │ └── source.cpp │ └── cli │ └── help.txt ├── src ├── common │ ├── Types.ts │ ├── TraceError.ts │ ├── ErrorUtils.ts │ ├── StringUtils.ts │ └── StringUtils.test.ts ├── cli │ ├── Types.ts │ ├── CliError.ts │ ├── Errors.ts │ ├── ArgumentParser.ts │ ├── HelpFormatter.ts │ ├── Cli.test.ts │ ├── ArgumentParser.test.ts │ └── Cli.ts ├── parse │ ├── Errors.ts │ ├── FileUtils.ts │ ├── CppFileParser.ts │ ├── CppFileMerger.test.ts │ ├── CppFileParser.test.ts │ └── CppFileMerger.ts └── cli.ts ├── .gitattributes ├── jest.config.json ├── .yarnrc.yml ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | -------------------------------------------------------------------------------- /test/data/complex/zero.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // zero.h 4 | -------------------------------------------------------------------------------- /src/common/Types.ts: -------------------------------------------------------------------------------- 1 | export type ErrorCause = Error | string; 2 | -------------------------------------------------------------------------------- /test/data/complex/second.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // second.hpp 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary 3 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/second.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // second.hpp 4 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/second.cpp: -------------------------------------------------------------------------------- 1 | #include "second.hpp" 2 | 3 | // second.cpp 4 | -------------------------------------------------------------------------------- /test/data/headerIncludeHeaderInSubdirectory/main.cpp: -------------------------------------------------------------------------------- 1 | #include "a.hpp" 2 | 3 | // main.cpp 4 | -------------------------------------------------------------------------------- /test/data/headerIncludeHeaderInSubdirectory/include/b.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // include/b.hpp 4 | -------------------------------------------------------------------------------- /test/data/complex/include/third.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../second.hpp" 4 | 5 | // third.hpp 6 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/include/one/two/third.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // one/two/third.hpp 4 | -------------------------------------------------------------------------------- /test/data/circularDependencies/second.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "first.hpp" 4 | 5 | // second.hpp 6 | -------------------------------------------------------------------------------- /test/data/headerIncludeHeaderInSubdirectory/a.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "b.hpp" 4 | 5 | // a.hpp 6 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/one/two/three/first.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // one/two/three/first.hpp 4 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/src/one/two/third.cpp: -------------------------------------------------------------------------------- 1 | #include "one/two/third.hpp" 2 | 3 | // third.cpp 4 | -------------------------------------------------------------------------------- /test/data/circularDependencies/main.cpp: -------------------------------------------------------------------------------- 1 | #include "first.hpp" 2 | 3 | #include 4 | 5 | // main.cpp 6 | -------------------------------------------------------------------------------- /test/data/complex/first.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "zero.h" 4 | 5 | #include 6 | 7 | // first.hpp 8 | -------------------------------------------------------------------------------- /test/data/headerIncludeHeaderInSubdirectory/expected.cpp: -------------------------------------------------------------------------------- 1 | 2 | // include/b.hpp 3 | 4 | // a.hpp 5 | 6 | // main.cpp 7 | -------------------------------------------------------------------------------- /test/data/complex/main.cpp: -------------------------------------------------------------------------------- 1 | #include "first.hpp" 2 | #include "third.hpp" 3 | 4 | #include 5 | 6 | // main.cpp 7 | -------------------------------------------------------------------------------- /test/data/circularDependencies/expected.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | // second.hpp 4 | 5 | // first.hpp 6 | 7 | // main.cpp 8 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/main.cpp: -------------------------------------------------------------------------------- 1 | #include "one/two/three/first.hpp" 2 | 3 | #include 4 | 5 | // main.cpp 6 | -------------------------------------------------------------------------------- /test/data/circularDependencies/first.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "second.hpp" 4 | 5 | #include 6 | 7 | // first.hpp 8 | -------------------------------------------------------------------------------- /test/data/complex/zero.c: -------------------------------------------------------------------------------- 1 | #include "zero.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | // zero.c 8 | -------------------------------------------------------------------------------- /test/data/complex/first.cpp: -------------------------------------------------------------------------------- 1 | #include "first.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | // first.cpp 8 | -------------------------------------------------------------------------------- /test/data/complex/second.cpp: -------------------------------------------------------------------------------- 1 | #include "second.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | // second.cpp 8 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "rootDir": "src", 4 | "moduleDirectories": [ 5 | "node_modules", 6 | "src" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/one/two/three/first.cpp: -------------------------------------------------------------------------------- 1 | #include "first.hpp" 2 | #include "../../../second.hpp" 3 | #include "one/two/third.hpp" 4 | 5 | // one/two/three/first.cpp 6 | -------------------------------------------------------------------------------- /test/data/missingInclude/main.cpp: -------------------------------------------------------------------------------- 1 | #include "hello.hpp" 2 | 3 | #include 4 | 5 | int main(int argc, const char* argv[]) { 6 | std::cout << factorial(6) << '\n'; 7 | return 0; 8 | } 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.5.1.cjs 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.{ts,js}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | /coverage 4 | /lib 5 | 6 | # IntelliJ 7 | /.idea 8 | 9 | # Yarn 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | yarn-error.log 18 | -------------------------------------------------------------------------------- /test/data/parse/header.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include "Test.hpp" 3 | #include "utils.hpp" 4 | #include "test/Test.h" 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | void test() { 11 | std::cout << "Hello world\n"; 12 | } 13 | -------------------------------------------------------------------------------- /test/data/sourceInSubdirectories/expected.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | // one/two/three/first.hpp 4 | 5 | // main.cpp 6 | 7 | // second.hpp 8 | 9 | // one/two/third.hpp 10 | 11 | // one/two/three/first.cpp 12 | 13 | // second.cpp 14 | 15 | // third.cpp 16 | -------------------------------------------------------------------------------- /test/data/complex/expected.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | // zero.h 6 | 7 | // first.hpp 8 | 9 | // second.hpp 10 | 11 | // third.hpp 12 | 13 | // main.cpp 14 | 15 | // first.cpp 16 | 17 | // zero.c 18 | 19 | // second.cpp 20 | -------------------------------------------------------------------------------- /test/data/parse/source.cpp: -------------------------------------------------------------------------------- 1 | #include "Test.hpp" 2 | #include "utils.hpp" 3 | #include "test/Test.h" 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | int main(int argc, const char* argv[]) { 10 | std::cout << "Hello world\n"; 11 | return 0; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node10/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "outDir": "./lib" 8 | }, 9 | "exclude": [ 10 | "**/*.test.ts", 11 | "./lib/**/*" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/cli/Types.ts: -------------------------------------------------------------------------------- 1 | export type Argument = { 2 | name: string; 3 | description: string; 4 | valueName?: string; 5 | }; 6 | 7 | export type OptionValue = { 8 | name: string 9 | }; 10 | 11 | export type Option = { 12 | name: string; 13 | options: string[]; 14 | description: string; 15 | value?: OptionValue; 16 | }; 17 | -------------------------------------------------------------------------------- /src/common/TraceError.ts: -------------------------------------------------------------------------------- 1 | import {ErrorCause} from "./Types"; 2 | 3 | export class TraceError extends Error { 4 | public readonly cause?: Error; 5 | 6 | public constructor(message: string, cause?: ErrorCause) { 7 | super(message); 8 | if (cause) { 9 | this.cause = cause instanceof Error ? cause : new Error(cause); 10 | } 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/cli/CliError.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | ArgumentError = 1, 3 | ParseError = 2, 4 | WriteError = 3, 5 | UnexpectedError = 100 6 | } 7 | 8 | export default class CliError extends Error { 9 | public readonly errorCode: ErrorCode; 10 | 11 | public constructor(message: string, errorCode: ErrorCode) { 12 | super(message); 13 | this.errorCode = errorCode; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/ErrorUtils.ts: -------------------------------------------------------------------------------- 1 | import {EOL} from "os"; 2 | import {TraceError} from "./TraceError"; 3 | 4 | export function formatErrorStack(error: Error | string | unknown): string { 5 | if (error instanceof TraceError && error.cause) { 6 | return [ 7 | error.stack || error.message, 8 | "caused by", 9 | formatErrorStack(error.cause) 10 | ].join(EOL); 11 | } 12 | 13 | if (error instanceof Error) { 14 | return error.stack || error.message; 15 | } 16 | 17 | return `${error}`; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{matrix.os}} 8 | 9 | strategy: 10 | matrix: 11 | os: [ ubuntu-latest, windows-latest, macos-latest ] 12 | node-version: [ 18.x, 20.x, 21.x ] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup Node.js ${{matrix.node-version}} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{matrix.node-version}} 20 | - run: npm install 21 | - run: npm run build 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /src/parse/Errors.ts: -------------------------------------------------------------------------------- 1 | import {TraceError} from "common/TraceError"; 2 | import {ErrorCause} from "common/Types"; 3 | 4 | export class ParseError extends TraceError { 5 | public readonly file: string; 6 | 7 | public constructor(message: string, file: string, cause?: ErrorCause) { 8 | super(message, cause); 9 | this.file = file; 10 | } 11 | } 12 | 13 | export class FileReadError extends ParseError { 14 | public constructor(message: string, file: string, cause?: ErrorCause) { 15 | super(message, file, cause); 16 | } 17 | } 18 | 19 | export class IncludeFileNotFoundError extends ParseError { 20 | public readonly includeFile: string; 21 | 22 | public constructor(file: string, includeFile: string, cause?: ErrorCause) { 23 | super(`Include file '${includeFile}' not found`, file, cause); 24 | this.includeFile = includeFile; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cli/Errors.ts: -------------------------------------------------------------------------------- 1 | import {TraceError} from "common/TraceError"; 2 | 3 | export class ArgumentError extends TraceError { 4 | public readonly argument: string; 5 | 6 | public constructor(message: string, argument: string) { 7 | super(message); 8 | this.argument = argument; 9 | } 10 | } 11 | 12 | export class UnknownArgumentError extends ArgumentError { 13 | public constructor(argument: string, message = `Unknown argument '${argument}'`) { 14 | super(message, argument); 15 | } 16 | } 17 | 18 | export class UnknownOptionError extends ArgumentError { 19 | public constructor(argument: string, message = `Unknown option '${argument}'`) { 20 | super(message, argument); 21 | } 22 | } 23 | 24 | export class OptionArgumentExpectedError extends ArgumentError { 25 | public constructor(option: string, message = `Option '${option}' requires a value`) { 26 | super(message, option); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {EOL} from "os"; 3 | import {argv, exit, stderr} from "process"; 4 | import Cli from "./cli/Cli"; 5 | import CliError, {ErrorCode} from "./cli/CliError"; 6 | 7 | import {formatErrorStack} from "./common/ErrorUtils"; 8 | 9 | const unexpectedErrorMessage = `Sorry, an unexpected error has occurred :-( 10 | It would be great if you spend few minutes and report it at: 11 | 12 | https://github.com/RandomVoid/cpp-merge/issues/new 13 | 14 | Please attach following information: 15 | `; 16 | 17 | const cli = new Cli(); 18 | 19 | try { 20 | const args = argv.slice(2); 21 | cli.run(args); 22 | } catch (error) { 23 | if (error instanceof CliError) { 24 | stderr.write(error.message); 25 | stderr.write(EOL); 26 | exit(error.errorCode); 27 | } 28 | stderr.write(unexpectedErrorMessage); 29 | stderr.write(formatErrorStack(error)); 30 | stderr.write(EOL); 31 | exit(ErrorCode.UnexpectedError); 32 | } 33 | -------------------------------------------------------------------------------- /src/parse/FileUtils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import {FileReadError} from "./Errors"; 4 | 5 | /** 6 | * Searches for file in specified directories. 7 | * @param fileName - Name of searched file. 8 | * @param searchDirectories - List of directories where file will be searched for. 9 | * @return Full path to the found file or `undefined` if file was not found in any of search directories. 10 | */ 11 | export function findFile(fileName: string, searchDirectories: string[]): string | undefined { 12 | for (const searchDirectory of searchDirectories) { 13 | const searchFilePath = path.resolve(searchDirectory, fileName); 14 | if (fs.existsSync(searchFilePath)) { 15 | return searchFilePath; 16 | } 17 | } 18 | } 19 | 20 | export function readFile(filePath: string): string { 21 | try { 22 | return fs.readFileSync(filePath, "utf-8"); 23 | } catch (error) { 24 | const cause = error instanceof Error ? error : undefined; 25 | throw new FileReadError(`Error reading file '${filePath}'`, filePath, cause); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 RandomVoid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/common/StringUtils.ts: -------------------------------------------------------------------------------- 1 | import {EOL} from "os"; 2 | 3 | const doubleLineRegExp = new RegExp(`(${EOL}){2,}`, 'g'); 4 | const doubleLineReplaceValue = `$1${EOL}`; 5 | 6 | export function removeDoubleEmptyLines(content: string): string { 7 | return content.replace(doubleLineRegExp, doubleLineReplaceValue); 8 | } 9 | 10 | export function limitLineLength(text: string, lineLength: number): string[] { 11 | if (lineLength <= 0 || text.length <= lineLength) { 12 | return [text]; 13 | } 14 | 15 | const resultLines: string[] = []; 16 | const textLines = text.split(EOL); 17 | 18 | for (const textLine of textLines) { 19 | let rest = textLine; 20 | 21 | while (rest.length > lineLength) { 22 | const part = rest.substring(0, lineLength + 1); 23 | const spacePosition = part.lastIndexOf(' '); 24 | if (spacePosition > 0) { 25 | resultLines.push(part.substring(0, spacePosition)); 26 | rest = rest.substring(spacePosition + 1); 27 | continue; 28 | } 29 | 30 | resultLines.push(part.substring(0, lineLength)); 31 | rest = rest.substring(lineLength); 32 | } 33 | 34 | resultLines.push(rest); 35 | } 36 | 37 | return resultLines; 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cpp-merge", 3 | "version": "0.4.3", 4 | "description": "Command line tool to produce single source file from multiple C/C++ files", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+ssh://git@github.com/FastAlien/cpp-merge.git" 8 | }, 9 | "homepage": "https://github.com/FastAlien/cpp-merge", 10 | "author": "FastAlien (https://github.com/FastAlien)", 11 | "license": "MIT", 12 | "files": [ 13 | "lib/**/*.js", 14 | "lib/**/*.d.ts" 15 | ], 16 | "main": "lib/cli.js", 17 | "types": "lib/cli.d.ts", 18 | "engines": { 19 | "node": ">=14" 20 | }, 21 | "scripts": { 22 | "build": "tsc", 23 | "lint": "eslint . --ext .ts", 24 | "test": "jest --coverage --coverageReporters=text", 25 | "prepublishOnly": "yarn build && chmod +x lib/cli.js && yarn lint && yarn test" 26 | }, 27 | "bin": { 28 | "cpp-merge": "lib/cli.js" 29 | }, 30 | "devDependencies": { 31 | "@tsconfig/node10": "^1.0.9", 32 | "@types/jest": "^29.5.12", 33 | "@types/node": "^20.11.19", 34 | "@typescript-eslint/eslint-plugin": "^7.0.1", 35 | "@typescript-eslint/parser": "^7.0.1", 36 | "eslint": "^8.56.0", 37 | "jest": "^29.7.0", 38 | "ts-jest": "^29.1.2", 39 | "typescript": "^5.3.3" 40 | }, 41 | "keywords": [ 42 | "c", 43 | "c++", 44 | "cpp", 45 | "file", 46 | "hpp", 47 | "merge", 48 | "single", 49 | "source" 50 | ], 51 | "packageManager": "yarn@3.5.1" 52 | } 53 | -------------------------------------------------------------------------------- /test/data/cli/help.txt: -------------------------------------------------------------------------------- 1 | cpp-merge 2 | 3 | A tool to produce single file from multiple C/C++ files. By default the produced 4 | content is displayed on the standard output. To store it in a file use option -o 5 | or --output. 6 | 7 | Syntax: 8 | cpp-merge [OPTIONS] 9 | 10 | Arguments: 11 | file 12 | Input file which will be processed. In most cases it will be 13 | file with main function. 14 | 15 | Options: 16 | --help 17 | Show this help text. 18 | 19 | -i , --include 20 | Path to additional directory where header files are located. 21 | Program will search for include files first in directory where 22 | currently processed file is located and then in this directory. 23 | 24 | -s , --source 25 | Path to additional directory where source files are located. 26 | After processing all included files, program will try to find 27 | related source file for each of included local header files. If 28 | file with same base name and extension .c or .cpp exists, it 29 | will be appended to the output. Program will search first in the 30 | same directory where main source file is located and then in 31 | additional source directory. 32 | 33 | -o , --output 34 | Store output in a file, instead of displaying it on the standard 35 | output. 36 | -------------------------------------------------------------------------------- /src/parse/CppFileParser.ts: -------------------------------------------------------------------------------- 1 | import {EOL} from "os"; 2 | 3 | export type ParseResult = { 4 | processOnce: boolean; 5 | localIncludes: string[]; 6 | systemIncludes: string[]; 7 | content: string; 8 | } 9 | 10 | export default class CppFileParser { 11 | private static readonly localIncludeRegExp = /^#include "([^"]+)"/; 12 | private static readonly systemIncludeRegExp = /^#include <([^>]+)>/; 13 | private static readonly pragmaOnceRegExp = /^#pragma once/; 14 | 15 | public parse(fileContent: string): ParseResult { 16 | const fileLines = fileContent.split(EOL); 17 | const content: string[] = []; 18 | const systemIncludes = new Set(); 19 | const localIncludes = new Set(); 20 | let pragmaOnceFound = false; 21 | let emptyCount = 0; 22 | 23 | for (const line of fileLines) { 24 | if (CppFileParser.pragmaOnceRegExp.test(line)) { 25 | pragmaOnceFound = true; 26 | continue; 27 | } 28 | 29 | const systemInclude = CppFileParser.systemIncludeRegExp.exec(line)?.[1]; 30 | if (systemInclude) { 31 | systemIncludes.add(systemInclude); 32 | continue; 33 | } 34 | 35 | const localInclude = CppFileParser.localIncludeRegExp.exec(line)?.[1]; 36 | if (localInclude) { 37 | localIncludes.add(localInclude); 38 | continue; 39 | } 40 | 41 | if (line) { 42 | emptyCount = 0; 43 | } else if (++emptyCount > 1) { 44 | continue; 45 | } 46 | 47 | content.push(line); 48 | } 49 | 50 | return { 51 | processOnce: pragmaOnceFound, 52 | localIncludes: Array.from(localIncludes.values()), 53 | systemIncludes: Array.from(systemIncludes.values()), 54 | content: content.join(EOL) 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/parse/CppFileMerger.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import CppFileMerger from "./CppFileMerger"; 3 | import {IncludeFileNotFoundError} from "./Errors"; 4 | 5 | describe("Parsing source file with missing include file", () => { 6 | const merger = new CppFileMerger(); 7 | 8 | test("Error is thrown", () => { 9 | expect(() => merger.parse("test/data/missingInclude/main.cpp")) 10 | .toThrowError(IncludeFileNotFoundError); 11 | }); 12 | }); 13 | 14 | describe("Merging complex source file", () => { 15 | const dataDirectory = "test/data/complex"; 16 | const merger = new CppFileMerger({ 17 | includeDirectory: `${dataDirectory}/include`, 18 | sourceDirectory: `${dataDirectory}/src` 19 | }); 20 | const content = merger.parse(`${dataDirectory}/main.cpp`); 21 | 22 | test("Generated content equals expected", () => { 23 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, "utf-8"); 24 | expect(content).toEqual(expected); 25 | }); 26 | }); 27 | 28 | describe("Merging source files in subdirectories", () => { 29 | const dataDirectory = "test/data/sourceInSubdirectories"; 30 | const merger = new CppFileMerger({ 31 | includeDirectory: `${dataDirectory}/include`, 32 | sourceDirectory: `${dataDirectory}/src` 33 | }); 34 | const content = merger.parse(`${dataDirectory}/main.cpp`); 35 | 36 | test("Generated content equals expected", () => { 37 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, "utf-8"); 38 | expect(content).toEqual(expected); 39 | }); 40 | }); 41 | 42 | describe("Merging files with circular dependencies", () => { 43 | const dataDirectory = "test/data/circularDependencies"; 44 | const merger = new CppFileMerger(); 45 | const content = merger.parse(`${dataDirectory}/main.cpp`); 46 | 47 | test("Generated content equals expected", () => { 48 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, "utf-8"); 49 | expect(content).toEqual(expected); 50 | }); 51 | }); 52 | 53 | describe("Merging source with header including another header from include directory", () => { 54 | const dataDirectory = "test/data/headerIncludeHeaderInSubdirectory"; 55 | const merger = new CppFileMerger({ 56 | includeDirectory: `${dataDirectory}/include` 57 | }); 58 | const content = merger.parse(`${dataDirectory}/main.cpp`); 59 | 60 | test("Generated content equals expected", () => { 61 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, "utf-8"); 62 | expect(content).toEqual(expected); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/parse/CppFileParser.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import CppFileParser from "./CppFileParser"; 3 | 4 | const parser = new CppFileParser(); 5 | 6 | describe("Parsing empty file", () => { 7 | const result = parser.parse(""); 8 | 9 | test("Result is empty", () => { 10 | expect(result.processOnce).toBeFalsy(); 11 | expect(result.localIncludes).toHaveLength(0); 12 | expect(result.systemIncludes).toHaveLength(0); 13 | expect(result.content).toEqual(""); 14 | }); 15 | }); 16 | 17 | describe("Parsing header file", () => { 18 | const content = fs.readFileSync("test/data/parse/header.hpp", "utf-8"); 19 | const result = parser.parse(content); 20 | 21 | test("Flag processOnce is enabled", () => { 22 | expect(result.processOnce).toBeTruthy(); 23 | }); 24 | 25 | test("#pragma once is removed from content", () => { 26 | expect(result.content).not.toContain("#pragma once"); 27 | }); 28 | 29 | test("Local includes are parsed", () => { 30 | expect(result.localIncludes).toHaveLength(3); 31 | expect(result.localIncludes).toContain("Test.hpp"); 32 | expect(result.localIncludes).toContain("utils.hpp"); 33 | expect(result.localIncludes).toContain("test/Test.h"); 34 | }); 35 | 36 | test("System includes are parsed", () => { 37 | expect(result.systemIncludes).toHaveLength(3); 38 | expect(result.systemIncludes).toContain("algorithm"); 39 | expect(result.systemIncludes).toContain("cmath"); 40 | expect(result.systemIncludes).toContain("iostream"); 41 | }); 42 | 43 | test("Includes are removed from content", () => { 44 | expect(result.content).not.toContain("#include"); 45 | }); 46 | }); 47 | 48 | describe("Parsing source file", () => { 49 | const content = fs.readFileSync("test/data/parse/source.cpp", "utf-8"); 50 | const result = parser.parse(content); 51 | 52 | test("Can be processed multiple times", () => { 53 | expect(result.processOnce).toBeFalsy(); 54 | }); 55 | 56 | test("Local includes are parsed", () => { 57 | expect(result.localIncludes).toHaveLength(3); 58 | expect(result.localIncludes).toContain("Test.hpp"); 59 | expect(result.localIncludes).toContain("utils.hpp"); 60 | expect(result.localIncludes).toContain("test/Test.h"); 61 | }); 62 | 63 | test("System includes are parsed", () => { 64 | expect(result.systemIncludes).toHaveLength(3); 65 | expect(result.systemIncludes).toContain("algorithm"); 66 | expect(result.systemIncludes).toContain("cmath"); 67 | expect(result.systemIncludes).toContain("iostream"); 68 | }); 69 | 70 | test("Includes are removed from content", () => { 71 | expect(result.content).not.toContain("#include"); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/common/StringUtils.test.ts: -------------------------------------------------------------------------------- 1 | import {EOL} from "os"; 2 | import {limitLineLength} from "./StringUtils"; 3 | 4 | describe("limitLineWidth", () => { 5 | const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; 6 | 7 | test("When parsing string with length < line width, then no EOL is added", () => { 8 | expect(limitLineLength(text, text.length + 1).join(EOL)) 9 | .toBe(text); 10 | }); 11 | 12 | test("When parsing string with length == line width, then no EOL is added", () => { 13 | expect(limitLineLength(text, text.length).join(EOL)) 14 | .toBe(text); 15 | }); 16 | 17 | test("When parsing string with length > max line length, then line length in result is less than or equal to limit", () => { 18 | expect(limitLineLength(text, 70).join(EOL)) 19 | .toBe(`Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do${EOL}eiusmod tempor incididunt ut labore et dolore magna aliqua.`); 20 | 21 | expect(limitLineLength(text, 25).join(EOL)) 22 | .toBe(`Lorem ipsum dolor sit${EOL}amet, consectetur${EOL}adipiscing elit, sed do${EOL}eiusmod tempor incididunt${EOL}ut labore et dolore magna${EOL}aliqua.`); 23 | }); 24 | 25 | test("When parsing string without spaces, then max line length in result is equal to limit", () => { 26 | const textWithoutSpaces = "Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliqua."; 27 | 28 | expect(limitLineLength(textWithoutSpaces, 30).join(EOL)) 29 | .toBe(`Loremipsumdolorsitametconsecte${EOL}turadipiscingelitseddoeiusmodt${EOL}emporincididuntutlaboreetdolor${EOL}emagnaaliqua.`); 30 | 31 | expect(limitLineLength(textWithoutSpaces, 10).join(EOL)) 32 | .toBe(`Loremipsum${EOL}dolorsitam${EOL}etconsecte${EOL}turadipisc${EOL}ingelitsed${EOL}doeiusmodt${EOL}emporincid${EOL}iduntutlab${EOL}oreetdolor${EOL}emagnaaliq${EOL}ua.`); 33 | }); 34 | 35 | test("When parsing string with it's already broken at correct places, then result it's not changed", () => { 36 | const textWithEOLs = `Loremipsumdolorsitametconsecte${EOL}turadipiscingelitseddoeiusmodt${EOL}emporincididuntut labore et${EOL}dolore magna aliqua.`; 37 | expect(limitLineLength(textWithEOLs, 30).join(EOL)) 38 | .toBe(textWithEOLs); 39 | }); 40 | 41 | test("When parsing string with multiple line breaks in line, then result respect those breaks", () => { 42 | const textWithEOLs = `${EOL}Lore${EOL}mipsum${EOL}dolorsitametco sectet. adipisc ingeli seddoei smodt.`; 43 | 44 | expect(limitLineLength(textWithEOLs, 30).join(EOL)) 45 | .toBe(`${EOL}Lore${EOL}mipsum${EOL}dolorsitametco sectet. adipisc${EOL}ingeli seddoei smodt.`); 46 | 47 | expect(limitLineLength(textWithEOLs, 15).join(EOL)) 48 | .toBe(`${EOL}Lore${EOL}mipsum${EOL}dolorsitametco${EOL}sectet. adipisc${EOL}ingeli seddoei${EOL}smodt.`); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/cli/ArgumentParser.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import HelpFormatter from "./HelpFormatter"; 3 | import {Argument, Option} from "./Types"; 4 | import {OptionArgumentExpectedError, UnknownArgumentError, UnknownOptionError} from "./Errors"; 5 | 6 | export type ParseResult = { 7 | arguments: ParsedArguments; 8 | options: ParsedOptions; 9 | }; 10 | 11 | type ParsedArguments = { 12 | [name: string]: string | undefined; 13 | }; 14 | 15 | type ParsedOptions = { 16 | [name: string]: string | undefined; 17 | }; 18 | 19 | export default class ArgumentParser { 20 | private readonly helpFormatter = new HelpFormatter(); 21 | private readonly arguments: Argument[] = []; 22 | private readonly options: Option[] = []; 23 | private readonly programName; 24 | private readonly description; 25 | 26 | public constructor(params: { programName: string, description: string }) { 27 | this.programName = params.programName; 28 | this.description = params.description; 29 | } 30 | 31 | public addArgument(argument: Argument): void { 32 | this.arguments.push(argument); 33 | } 34 | 35 | public addOption(option: Option): void { 36 | this.options.push(option); 37 | } 38 | 39 | public parseArguments(args: string[]): ParseResult { 40 | const argumentsToParse = [...args]; 41 | const parsedArguments: ParsedArguments = {}; 42 | const parsedOptions: ParsedOptions = {}; 43 | let argumentIndex = 0; 44 | 45 | while (argumentsToParse.length > 0) { 46 | const argument = argumentsToParse.shift(); 47 | assert(argument); 48 | if (this.isOption(argument)) { 49 | const {name, value} = this.parseOption(argument, argumentsToParse); 50 | parsedOptions[name] = value; 51 | continue; 52 | } 53 | 54 | if (argumentIndex >= this.arguments.length) { 55 | throw new UnknownArgumentError(argument); 56 | } 57 | 58 | const argumentName = this.arguments[argumentIndex].name; 59 | parsedArguments[argumentName] = argument; 60 | ++argumentIndex; 61 | } 62 | 63 | return { 64 | arguments: parsedArguments, 65 | options: parsedOptions 66 | }; 67 | } 68 | 69 | public formatHelp(): string { 70 | return this.helpFormatter.formatHelp(this.programName, this.description, this.arguments, this.options); 71 | } 72 | 73 | private isOption(argument: string) { 74 | return argument.startsWith("-"); 75 | } 76 | 77 | private parseOption(argument: string, args: string[]): { name: string, value: string } { 78 | const option = this.findOption(argument); 79 | if (!option) { 80 | throw new UnknownOptionError(argument); 81 | } 82 | 83 | if (!option.value) { 84 | return {name: option.name, value: ""}; 85 | } 86 | 87 | const value = args.shift(); 88 | if (!value) { 89 | throw new OptionArgumentExpectedError(argument); 90 | } 91 | return {name: option.name, value: value}; 92 | } 93 | 94 | private findOption(option: string): Option | undefined { 95 | return this.options.find((opt: Option) => { 96 | return opt.options.find(o => o === option); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cpp-merge 2 | 3 | Tool to produce a single source file from multiple C/C++ files. It was developed mainly to be used in programming 4 | contests that require submitting a solution as a single source file. 5 | 6 | ## Install 7 | 8 | #### Prerequisites 9 | 10 | * Node.js 11 | * npm 12 | * Yarn (optional) 13 | 14 | #### Installation from npm 15 | 16 | ```shell 17 | npm install -g cpp-merge 18 | ``` 19 | 20 | #### Installation from source 21 | 22 | ##### Clone git repository 23 | 24 | ```shell 25 | git clone git@github.com:FastAlien/cpp-merge.git 26 | ``` 27 | 28 | ##### Build 29 | 30 | Go to the project directory and run the following commands. 31 | 32 | Using npm: 33 | 34 | ```shell 35 | npm ci 36 | npm run build 37 | ``` 38 | 39 | Using Yarn: 40 | 41 | ```shell 42 | yarn install 43 | yarn build 44 | ``` 45 | 46 | ##### Link package 47 | 48 | Go to the project directory and run the following command: 49 | 50 | ```shell 51 | npm -g link 52 | ``` 53 | 54 | ## Usage 55 | 56 | This tool will produce a single source file from multiple C/C++ files. By default produced content is displayed on the 57 | standard output. To save it into a file use option `-o` or `--output`. 58 | 59 | File passed as an argument will be processed similarly to what the preprocessor would do. It means all included local 60 | files (ex. `#include "header.hpp"`) will be processed and added to output in place of the include directive. Program 61 | will search for include files first in the directory where the currently processed file is located and then in 62 | additional include directory, if it was specified in program arguments (option `-i` or `--include`). 63 | 64 | Files containing `#pragma once` will be processed only once, so use this directive to avoid duplication of content of 65 | files in the output and to reduce its size. 66 | 67 | After processing all included files, the program will try to find related source files for each of included local header 68 | files. If a file with the same base name and extension .c or .cpp exists, it will be appended to the output. Program 69 | will search first in the same directory where the main source file is located and then in an additional source 70 | directory, if it was specified in program arguments (option `-s` or `--source`). If the header was included using 71 | relative path ex. `#include "one/two/three.hpp"` the program will search for `three.c` or `three.cpp` in `one/two/` 72 | or `${sourceDirectory}/one/two/`. First found file will be appended to the output. 73 | 74 | Program will detect duplication of system header includes, so output will contain only a unique set of them, ordered 75 | alphabetically. Any of the processed header and source files will not be changed. 76 | 77 | Display the build-in help: 78 | 79 | ```shell 80 | cpp-merge --help 81 | ``` 82 | 83 | #### Usage examples 84 | 85 | Process `main.cpp` and display produced content on the standard output: 86 | 87 | ```shell 88 | cpp-merge main.cpp 89 | ``` 90 | 91 | Process `main.cpp` and save output to file `output.cpp`: 92 | 93 | ```shell 94 | cpp-merge --output output.cpp main.cpp 95 | ``` 96 | 97 | Specify additional include and source directory: 98 | 99 | ```shell 100 | cpp-merge --include ../include --source ../src main.cpp 101 | ``` 102 | -------------------------------------------------------------------------------- /src/cli/HelpFormatter.ts: -------------------------------------------------------------------------------- 1 | import {EOL} from "os"; 2 | import {limitLineLength} from "common/StringUtils"; 3 | import {Argument, Option} from "./Types"; 4 | 5 | export default class HelpFormatter { 6 | private readonly syntaxTitle = "Syntax"; 7 | private readonly argumentsTitle = "Arguments"; 8 | private readonly optionsTitle = "Options"; 9 | private readonly lineMaxLength = 80; 10 | private readonly smallMargin = " ".repeat(2); 11 | private readonly largeMargin = " ".repeat(16); 12 | private readonly descriptionMaxLength = this.lineMaxLength - this.largeMargin.length; 13 | 14 | public formatHelp(programName: string, description: string, args: Argument[], options: Option[]): string { 15 | let help = programName; 16 | help += EOL; 17 | help += EOL; 18 | help += limitLineLength(description, this.lineMaxLength).join(EOL); 19 | help += EOL; 20 | 21 | help += this.formatSectionTitle(this.syntaxTitle); 22 | help += this.formatSyntaxHelp(programName, args, options); 23 | help += EOL; 24 | 25 | if (args.length > 0) { 26 | help += this.formatArgumentsHelp(args); 27 | } 28 | 29 | if (options.length > 0) { 30 | help += this.formatOptionsHelp(options); 31 | } 32 | 33 | return help; 34 | } 35 | 36 | public formatSyntaxHelp(programName: string, args: Argument[], options: Option[]): string { 37 | let help = this.smallMargin; 38 | help += programName; 39 | if (options.length > 0) { 40 | help += ` [${this.optionsTitle.toUpperCase()}]`; 41 | } 42 | 43 | for (const argument of args) { 44 | const valueName = argument.valueName || argument.name; 45 | help += ` <${valueName}>`; 46 | } 47 | 48 | return help; 49 | } 50 | 51 | public formatArgumentHelp(argument: Argument): string { 52 | let help = this.smallMargin; 53 | help += argument.valueName || argument.name; 54 | help += EOL; 55 | help += this.formatDescription(argument.description); 56 | return help; 57 | } 58 | 59 | public formatOptionHelp(option: Option): string { 60 | let help = this.smallMargin; 61 | help += option.options.map(opt => { 62 | let optionHelp = opt; 63 | if (option.value) { 64 | optionHelp += ` <${option.value.name}>`; 65 | } 66 | return optionHelp; 67 | }).join(", "); 68 | 69 | help += EOL; 70 | help += this.formatDescription(option.description); 71 | return help; 72 | } 73 | 74 | private formatSectionTitle(title: string) { 75 | let help = EOL; 76 | help += `${title}:`; 77 | help += EOL; 78 | return help; 79 | } 80 | 81 | private formatArgumentsHelp(args: Argument[]): string { 82 | let help = this.formatSectionTitle(this.argumentsTitle); 83 | let separator = ""; 84 | for (const argument of args) { 85 | help += separator; 86 | help += this.formatArgumentHelp(argument); 87 | separator = EOL; 88 | } 89 | return help; 90 | } 91 | 92 | private formatDescription(description: string): string { 93 | let help = ""; 94 | const descriptionLines = limitLineLength(description, this.descriptionMaxLength); 95 | descriptionLines.forEach(line => { 96 | help += this.largeMargin; 97 | help += line; 98 | help += EOL; 99 | }); 100 | return help; 101 | } 102 | 103 | private formatOptionsHelp(options: Option[]): string { 104 | let help = this.formatSectionTitle(this.optionsTitle); 105 | let separator = ""; 106 | for (const option of options) { 107 | help += separator; 108 | help += this.formatOptionHelp(option); 109 | separator = EOL; 110 | } 111 | return help; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/parse/CppFileMerger.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {EOL} from "os"; 3 | import {removeDoubleEmptyLines} from "common/StringUtils"; 4 | import CppFileParser from "./CppFileParser"; 5 | import {IncludeFileNotFoundError} from "./Errors"; 6 | import {findFile, readFile} from "./FileUtils"; 7 | 8 | const headerFileExtensions = [".h", ".hpp"]; 9 | const sourceFileExtensions = [".c", ".cpp"]; 10 | 11 | export default class CppFileMerger { 12 | private readonly parser = new CppFileParser(); 13 | private readonly includeDirectory: string | undefined; 14 | private readonly sourceDirectory: string | undefined; 15 | private readonly systemIncludes = new Set(); 16 | private readonly processedOnce = new Set(); 17 | private workingDirectory = ""; 18 | 19 | public constructor(params: { includeDirectory?: string, sourceDirectory?: string } = {}) { 20 | this.includeDirectory = params.includeDirectory ? path.resolve(params.includeDirectory) : undefined; 21 | this.sourceDirectory = params.sourceDirectory ? path.resolve(params.sourceDirectory) : undefined; 22 | } 23 | 24 | public parse(filePath: string): string { 25 | this.systemIncludes.clear(); 26 | this.processedOnce.clear(); 27 | this.workingDirectory = path.dirname(filePath); 28 | const content = this.parseFile(filePath); 29 | const sourceFilesContent = this.parseSourceFiles(); 30 | const systemIncludesContent = Array.from(this.systemIncludes.values()) 31 | .sort((a, b) => a.localeCompare(b)) 32 | .map(file => `#include <${file}>`) 33 | .join(EOL); 34 | 35 | const finalContent = [ 36 | systemIncludesContent, 37 | content, 38 | sourceFilesContent 39 | ] 40 | .filter(content => !!content) 41 | .join(EOL); 42 | 43 | return removeDoubleEmptyLines(finalContent); 44 | } 45 | 46 | private parseFile(filePath: string): string { 47 | if (this.processedOnce.has(filePath)) { 48 | return ""; 49 | } 50 | 51 | const fileContent = readFile(filePath); 52 | const result = this.parser.parse(fileContent); 53 | result.systemIncludes.forEach(include => this.systemIncludes.add(include)); 54 | 55 | if (result.processOnce) { 56 | this.processedOnce.add(filePath); 57 | } 58 | 59 | const currentDirectory = path.dirname(filePath); 60 | const localIncludesContent = result.localIncludes.map(includeFilePath => { 61 | return this.parseIncludedFile(includeFilePath, currentDirectory, filePath) 62 | }); 63 | 64 | return [ 65 | ...localIncludesContent, 66 | result.content 67 | ].join(EOL); 68 | } 69 | 70 | private parseIncludedFile(filePath: string, currentDirectory: string, processedFilePath: string): string { 71 | const searchFilePaths: string[] = [currentDirectory]; 72 | if (this.includeDirectory) { 73 | searchFilePaths.push(this.includeDirectory); 74 | } 75 | 76 | const foundFilePath = findFile(filePath, searchFilePaths); 77 | if (!foundFilePath) { 78 | throw new IncludeFileNotFoundError(processedFilePath, filePath); 79 | } 80 | 81 | return this.parseFile(foundFilePath); 82 | } 83 | 84 | private parseSourceFiles(): string { 85 | const contents: string[] = []; 86 | const searchDirectories: string[] = [this.workingDirectory]; 87 | if (this.sourceDirectory) { 88 | searchDirectories.push(this.sourceDirectory); 89 | } 90 | 91 | this.processedOnce.forEach(filePath => { 92 | const relativeFilePath = this.getHeaderRelativePath(filePath); 93 | const extension = path.extname(relativeFilePath); 94 | if (!headerFileExtensions.find(headerFileExtension => headerFileExtension === extension)) { 95 | return; 96 | } 97 | 98 | const relativeFilePathWithoutExtension = relativeFilePath.substr(0, relativeFilePath.length - extension.length); 99 | for (const sourceFileExtension of sourceFileExtensions) { 100 | const sourceFileName = `${relativeFilePathWithoutExtension}${sourceFileExtension}`; 101 | const foundFilePath = findFile(sourceFileName, searchDirectories); 102 | if (!foundFilePath) { 103 | continue; 104 | } 105 | 106 | const sourceFileContent = this.parseFile(foundFilePath); 107 | if (sourceFileContent) { 108 | contents.push(sourceFileContent); 109 | } 110 | } 111 | }); 112 | 113 | return contents.join(EOL); 114 | } 115 | 116 | private getHeaderRelativePath(filePath: string) { 117 | const fileDirectory = path.dirname(filePath); 118 | const baseDirectory = this.includeDirectory && fileDirectory.startsWith(this.includeDirectory) ? this.includeDirectory : this.workingDirectory; 119 | return path.relative(baseDirectory, filePath); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/cli/Cli.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import os from "os"; 3 | import {Writable} from "stream"; 4 | import Cli from "./Cli"; 5 | import CliError, {ErrorCode} from "./CliError"; 6 | 7 | const fileEncoding = "utf-8"; 8 | 9 | describe("Parse incorrect arguments", () => { 10 | const notExistingInputFile = "nonExistingFile.cpp"; 11 | const notExistingDirectory = "notExistingDirectory.hpp"; 12 | const exitingInputFile = "test/data/parse/source.cpp"; 13 | const cli = new Cli(); 14 | 15 | test("When input file doesn't exists, then CliError is thrown", () => { 16 | expect(() => cli.run([notExistingInputFile])) 17 | .toThrowError(new CliError(`Input file '${notExistingInputFile}' doesn't exist`, ErrorCode.ArgumentError)); 18 | }); 19 | 20 | test("When include doesn't exists, then CliError is thrown", () => { 21 | const expectedError = new CliError(`Include directory '${notExistingDirectory}' doesn't exist`, ErrorCode.ArgumentError); 22 | 23 | expect(() => cli.run(["-i", notExistingDirectory, exitingInputFile])) 24 | .toThrowError(expectedError); 25 | 26 | expect(() => cli.run(["--include", notExistingDirectory, exitingInputFile])) 27 | .toThrowError(expectedError); 28 | }); 29 | 30 | test("When source directory doesn't exists, CliError is thrown", () => { 31 | const expectedError = new CliError(`Source directory '${notExistingDirectory}' doesn't exist`, ErrorCode.ArgumentError); 32 | 33 | expect(() => cli.run(["-s", notExistingDirectory, exitingInputFile])) 34 | .toThrowError(expectedError); 35 | 36 | expect(() => cli.run(["--source", notExistingDirectory, exitingInputFile])) 37 | .toThrowError(expectedError); 38 | }); 39 | }); 40 | 41 | describe("Parse correct arguments", () => { 42 | const dataDirectory = "test/data/sourceInSubdirectories"; 43 | const includeDirectory = `${dataDirectory}/include`; 44 | const sourceDirectory = `${dataDirectory}/src`; 45 | const inputFilePath = `${dataDirectory}/main.cpp`; 46 | const helpFilePath = "test/data/cli/help.txt"; 47 | 48 | test("When no arguments and options are passed, then help text is displayed on stdout", () => { 49 | const output = new StringWritableStream(); 50 | const cli = new Cli(output); 51 | cli.run([]); 52 | const helpText = fs.readFileSync(helpFilePath, fileEncoding); 53 | expect(output.data).toBe(helpText); 54 | }); 55 | 56 | test("When --help is passed, then help text is displayed on stdout", () => { 57 | const output = new StringWritableStream(); 58 | const cli = new Cli(output); 59 | cli.run(["--help"]); 60 | const helpText = fs.readFileSync(helpFilePath, fileEncoding); 61 | expect(output.data).toBe(helpText); 62 | }); 63 | 64 | test("When no -o/--output is passed, then output displayed on stdout", () => { 65 | const args = ["-i", includeDirectory, "-s", sourceDirectory, inputFilePath]; 66 | const output = new StringWritableStream(); 67 | const cli = new Cli(output); 68 | cli.run(args); 69 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, fileEncoding); 70 | expect(output.data).toStrictEqual(expected); 71 | }); 72 | 73 | test("When -o is passed, then output stored in a file", () => { 74 | const tempDir = fs.mkdtempSync(`${os.tmpdir()}/cpp-merge-test-cli-Cli-${Date.now()}-`); 75 | const outputFilePath = `${tempDir}/output.tmp`; 76 | const args = ["-i", includeDirectory, "-s", sourceDirectory, "-o", outputFilePath, inputFilePath]; 77 | const cli = new Cli(); 78 | cli.run(args); 79 | const output = fs.readFileSync(outputFilePath, fileEncoding); 80 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, fileEncoding); 81 | expect(output).toStrictEqual(expected); 82 | }); 83 | 84 | test("When --output is passed, then output stored in a file", () => { 85 | const tempDir = fs.mkdtempSync(`${os.tmpdir()}/cpp-merge-test-cli-Cli-${Date.now()}-`); 86 | const outputFilePath = `${tempDir}/output.tmp`; 87 | const args = ["-i", includeDirectory, "-s", sourceDirectory, "--output", outputFilePath, inputFilePath]; 88 | const cli = new Cli(); 89 | cli.run(args); 90 | const output = fs.readFileSync(outputFilePath, fileEncoding); 91 | const expected = fs.readFileSync(`${dataDirectory}/expected.cpp`, fileEncoding); 92 | expect(output).toStrictEqual(expected); 93 | }); 94 | }); 95 | 96 | class StringWritableStream extends Writable { 97 | public data = ""; 98 | 99 | public constructor() { 100 | super(); 101 | } 102 | 103 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 104 | public _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) { 105 | this.data = this.data + chunk.toString(); 106 | callback(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/cli/ArgumentParser.test.ts: -------------------------------------------------------------------------------- 1 | import ArgumentParser, {ParseResult} from "./ArgumentParser"; 2 | import {OptionArgumentExpectedError, UnknownArgumentError, UnknownOptionError} from "./Errors"; 3 | 4 | describe("ArgumentParser with one argument", () => { 5 | const parser = new ArgumentParser({programName: "test", description: "Test application"}); 6 | parser.addArgument({ 7 | name: "file", 8 | description: "Input file" 9 | }); 10 | 11 | test("When parsing unknown option, then UnknownOptionError is thrown", () => { 12 | expect(() => parser.parseArguments(["-o"])).toThrowError(new UnknownOptionError("-o")); 13 | expect(() => parser.parseArguments(["--output"])).toThrowError(new UnknownOptionError("--output")); 14 | }); 15 | 16 | test("When parsing one argument, then result contains argument with passed value", () => { 17 | const file = "input.cpp"; 18 | const expected: ParseResult = { 19 | arguments: {file: file}, 20 | options: {} 21 | }; 22 | expect(parser.parseArguments([file])).toEqual(expected); 23 | }); 24 | 25 | test("When parsing too many arguments, then UnknownArgumentError is thrown", () => { 26 | expect(() => parser.parseArguments(["input.cpp", "other"])).toThrowError(new UnknownArgumentError("other")); 27 | }); 28 | }); 29 | 30 | describe("ArgumentParser with argument without value", () => { 31 | const parser = new ArgumentParser({programName: "test", description: "Test application"}); 32 | parser.addOption({ 33 | name: "help", 34 | options: ["-h", "--help"], 35 | description: "Show help text." 36 | }); 37 | 38 | test("When parsing empty arguments, then option in result is undefined", () => { 39 | const parsedArguments = parser.parseArguments([]); 40 | expect(parsedArguments.options["help"]).toBeUndefined(); 41 | }); 42 | 43 | test("When parsing short option, then option in result is defined", () => { 44 | const parsedArguments = parser.parseArguments(["-h"]); 45 | expect(parsedArguments.options["help"]).toBeDefined(); 46 | }); 47 | 48 | test("When parsing long option, then option in result is defined", () => { 49 | const parsedArguments = parser.parseArguments(["--help"]); 50 | expect(parsedArguments.options["help"]).toBeDefined(); 51 | }); 52 | }); 53 | 54 | describe("ArgumentParser with one option with value", () => { 55 | const parser = new ArgumentParser({programName: "test", description: "Test application"}); 56 | parser.addOption({ 57 | name: "include", 58 | options: ["-i", "--include"], 59 | description: "Path to additional directory where header files are located.", 60 | value: {name: "path"} 61 | }); 62 | 63 | test("When parsing option without argument, then OptionArgumentExpectedError is thrown", () => { 64 | expect(() => parser.parseArguments(["-i"])).toThrowError(new OptionArgumentExpectedError("-i")); 65 | expect(() => parser.parseArguments(["--include"])).toThrowError(new OptionArgumentExpectedError("--include")); 66 | }); 67 | 68 | test("When parsing unknown option, then UnknownOptionError is thrown", () => { 69 | expect(() => parser.parseArguments(["-o"])).toThrowError(new UnknownOptionError("-o")); 70 | expect(() => parser.parseArguments(["--output"])).toThrowError(new UnknownOptionError("--output")); 71 | }); 72 | 73 | test("When parsing option with argument, then result contains option with passed value", () => { 74 | const includeDirectory = "some/path/to/directory"; 75 | const expected: ParseResult = { 76 | arguments: {}, 77 | options: {include: includeDirectory} 78 | }; 79 | expect(parser.parseArguments(["-i", includeDirectory])).toEqual(expected); 80 | expect(parser.parseArguments(["--include", includeDirectory])).toEqual(expected); 81 | }); 82 | }); 83 | 84 | describe("ArgumentParser with argument and option", () => { 85 | const parser = new ArgumentParser({programName: "test", description: "Test application"}); 86 | parser.addArgument({ 87 | name: "file", 88 | description: "Input file" 89 | }); 90 | 91 | parser.addOption({ 92 | name: "include", 93 | options: ["-i", "--include"], 94 | description: "Path to additional directory where header files are located.", 95 | value: {name: "path"} 96 | }); 97 | 98 | test("When parsing empty arguments and options, then arguments and options in result are empty", () => { 99 | const expected: ParseResult = {arguments: {}, options: {}}; 100 | expect(parser.parseArguments([])).toEqual(expected); 101 | }); 102 | 103 | test("When parsing one argument, then result contains argument with passed value", () => { 104 | const file = "input.cpp"; 105 | const expected: ParseResult = { 106 | arguments: {file: file}, 107 | options: {} 108 | }; 109 | expect(parser.parseArguments([file])).toEqual(expected); 110 | }); 111 | 112 | test("When parsing too many arguments, then UnknownArgumentError is thrown", () => { 113 | expect(() => parser.parseArguments(["input.cpp", "other"])).toThrowError(new UnknownArgumentError("other")); 114 | }); 115 | 116 | test("When parsing option without argument, then OptionArgumentExpectedError is thrown", () => { 117 | expect(() => parser.parseArguments(["-i"])).toThrowError(new OptionArgumentExpectedError("-i")); 118 | expect(() => parser.parseArguments(["--include"])).toThrowError(new OptionArgumentExpectedError("--include")); 119 | }); 120 | 121 | test("When parsing argument and option with argument, then result contains argument and option with passed values", () => { 122 | const file = "input.cpp"; 123 | const includeDirectory = "some/path/to/directory"; 124 | const expected: ParseResult = { 125 | arguments: {file: file}, 126 | options: {include: includeDirectory} 127 | }; 128 | expect(parser.parseArguments(["-i", includeDirectory, file])).toEqual(expected); 129 | expect(parser.parseArguments(["--include", includeDirectory, file])).toEqual(expected); 130 | expect(parser.parseArguments([file, "-i", includeDirectory])).toEqual(expected); 131 | expect(parser.parseArguments([file, "--include", includeDirectory])).toEqual(expected); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/cli/Cli.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import {Writable} from "stream"; 3 | import CppFileMerger from "parse/CppFileMerger"; 4 | import {ParseError} from "parse/Errors"; 5 | import ArgumentParser from "./ArgumentParser"; 6 | import CliError, {ErrorCode} from "./CliError"; 7 | import {ArgumentError, UnknownArgumentError, UnknownOptionError} from "./Errors"; 8 | 9 | enum ArgumentName { 10 | File = "file" 11 | } 12 | 13 | enum OptionName { 14 | Help = "help", 15 | Include = "include", 16 | Source = "source", 17 | Output = "output" 18 | } 19 | 20 | type Arguments = { 21 | help: boolean; 22 | inputFilePath?: string; 23 | includeDirectory?: string; 24 | sourceDirectory?: string; 25 | outputFilePath?: string; 26 | }; 27 | 28 | export default class Cli { 29 | private readonly output: Writable; 30 | private readonly argumentParser: ArgumentParser; 31 | 32 | public constructor(output: Writable = process.stdout) { 33 | this.output = output; 34 | this.argumentParser = new ArgumentParser({ 35 | programName: "cpp-merge", 36 | description: "A tool to produce single file from multiple C/C++ files. By default the produced content is " + 37 | "displayed on the standard output. To store it in a file use option -o or --output." 38 | }); 39 | 40 | this.argumentParser.addArgument({ 41 | name: ArgumentName.File, 42 | description: "Input file which will be processed. In most cases it will be file with main function.", 43 | valueName: "file" 44 | }); 45 | 46 | this.argumentParser.addOption({ 47 | name: OptionName.Help, 48 | options: ["--help"], 49 | description: "Show this help text." 50 | }); 51 | 52 | this.argumentParser.addOption({ 53 | name: OptionName.Include, 54 | options: ["-i", "--include"], 55 | description: "Path to additional directory where header files are located. Program will search for include " + 56 | "files first in directory where currently processed file is located and then in this directory.", 57 | value: {name: "path"} 58 | }); 59 | 60 | this.argumentParser.addOption({ 61 | name: OptionName.Source, 62 | options: ["-s", "--source"], 63 | description: "Path to additional directory where source files are located. After processing all included files, " + 64 | "program will try to find related source file for each of included local header files. If file with same " + 65 | "base name and extension .c or .cpp exists, it will be appended to the output. Program will search first " + 66 | "in the same directory where main source file is located and then in additional source directory.", 67 | value: {name: "path"} 68 | }); 69 | 70 | this.argumentParser.addOption({ 71 | name: OptionName.Output, 72 | options: ["-o", "--output"], 73 | description: "Store output in a file, instead of displaying it on the standard output.", 74 | value: {name: "file"} 75 | }); 76 | } 77 | 78 | public run(args: string[]): void { 79 | if (args.length === 0) { 80 | this.printHelp(); 81 | return; 82 | } 83 | 84 | try { 85 | const {help, inputFilePath, includeDirectory, sourceDirectory, outputFilePath} = this.parseArguments(args); 86 | if (help) { 87 | this.printHelp(); 88 | return; 89 | } 90 | 91 | const content = this.parseFile(inputFilePath, includeDirectory, sourceDirectory); 92 | if (outputFilePath) { 93 | this.writeToFile(outputFilePath, content); 94 | } else { 95 | this.output.write(content); 96 | } 97 | } catch (error) { 98 | if (error instanceof ParseError) { 99 | throw new CliError(`Error parsing file '${error.file}': ${error.message}`, ErrorCode.ParseError); 100 | } 101 | 102 | if (error instanceof UnknownOptionError) { 103 | throw new CliError(`Unknown option: '${error.argument}'`, ErrorCode.ArgumentError); 104 | } 105 | 106 | if (error instanceof UnknownArgumentError) { 107 | throw new CliError(`Unknown argument: '${error.argument}'`, ErrorCode.ArgumentError); 108 | } 109 | 110 | if (error instanceof ArgumentError) { 111 | throw new CliError(`Invalid argument '${error.argument}': ${error.message}`, ErrorCode.ArgumentError); 112 | } 113 | 114 | throw error; 115 | } 116 | } 117 | 118 | private printHelp() { 119 | this.output.write(this.argumentParser.formatHelp()); 120 | } 121 | 122 | private parseArguments(args: string[]): Arguments { 123 | const result = this.argumentParser.parseArguments(args); 124 | return { 125 | help: result.options[OptionName.Help] != null, 126 | inputFilePath: result.arguments[ArgumentName.File], 127 | includeDirectory: result.options[OptionName.Include], 128 | sourceDirectory: result.options[OptionName.Source], 129 | outputFilePath: result.options[OptionName.Output] 130 | }; 131 | } 132 | 133 | private parseFile(inputFilePath?: string, includeDirectory?: string, sourceDirectory?: string): string { 134 | if (!inputFilePath) { 135 | throw new CliError('Missing input file', ErrorCode.ArgumentError); 136 | } 137 | 138 | this.validateInputFile(inputFilePath); 139 | 140 | if (includeDirectory) { 141 | this.validateIncludeDirectory(includeDirectory); 142 | } 143 | 144 | if (sourceDirectory) { 145 | this.validateSourceDirectory(sourceDirectory); 146 | } 147 | 148 | const fileMerger = new CppFileMerger({includeDirectory, sourceDirectory}); 149 | return fileMerger.parse(inputFilePath); 150 | } 151 | 152 | private validateInputFile(inputFilePath: string) { 153 | if (!fs.existsSync(inputFilePath)) { 154 | throw new CliError(`Input file '${inputFilePath}' doesn't exist`, ErrorCode.ArgumentError); 155 | } 156 | 157 | if (!fs.statSync(inputFilePath).isFile()) { 158 | throw new CliError(`${inputFilePath} is not a file`, ErrorCode.ArgumentError); 159 | } 160 | } 161 | 162 | private validateIncludeDirectory(includeDirectory: string) { 163 | if (!fs.existsSync(includeDirectory)) { 164 | throw new CliError(`Include directory '${includeDirectory}' doesn't exist`, ErrorCode.ArgumentError); 165 | } 166 | 167 | if (!fs.statSync(includeDirectory).isDirectory()) { 168 | throw new CliError(`${includeDirectory} is not a directory`, ErrorCode.ArgumentError); 169 | } 170 | } 171 | 172 | private validateSourceDirectory(sourceDirectory: string) { 173 | if (!fs.existsSync(sourceDirectory)) { 174 | throw new CliError(`Source directory '${sourceDirectory}' doesn't exist`, ErrorCode.ArgumentError); 175 | } 176 | 177 | if (!fs.statSync(sourceDirectory).isDirectory()) { 178 | throw new CliError(`${sourceDirectory} is not a directory`, ErrorCode.ArgumentError); 179 | } 180 | } 181 | 182 | private writeToFile(outputFilePath: string, content: string) { 183 | try { 184 | fs.writeFileSync(outputFilePath, content) 185 | } catch (error) { 186 | const causeMessage = error instanceof Error ? error.message : 'Unknown error'; 187 | throw new CliError(`Error writing output to file '${outputFilePath}': ${causeMessage}`, ErrorCode.WriteError); 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------