| #!/usr/bin/env python3 | |
| # Tool to bundle multiple C/C++ source files, inlining any includes. | |
| # | |
| # Note: there are two types of exclusion options: the '-x' flag, which besides | |
| # excluding a file also adds an #error directive in place of the #include, and | |
| # the '-k' flag, which keeps the #include and doesn't inline the file. The | |
| # intended use cases are: '-x' for files that would normally be #if'd out, so | |
| # features that 100% won't be used in the amalgamated file, for which every | |
| # occurrence adds the error, and '-k' for headers that we wish to manually | |
| # include, such as a project's public API, for which occurrences after the first | |
| # are removed. | |
| # | |
| # Todo: the error handling could be better, which currently throws and halts | |
| # (which is functional just not very friendly). | |
| # | |
| # Author: Carl Woffenden, Numfum GmbH (this script is released under a CC0 license/Public Domain) | |
| import argparse, re, sys | |
| from pathlib import Path | |
| from typing import Any, List, Optional, Pattern, Set, TextIO | |
| # Set of file roots when searching (equivalent to -I paths for the compiler). | |
| roots: Set[Path] = set() | |
| # Set of (canonical) file Path objects to exclude from inlining (and not only | |
| # exclude but to add a compiler error directive when they're encountered). | |
| excludes: Set[Path] = set() | |
| # Set of (canonical) file Path objects to keep as include directives. | |
| keeps: Set[Path] = set() | |
| # Whether to keep the #pragma once directives (unlikely, since this will result | |
| # in a warning, but the option is there). | |
| keep_pragma: bool = False | |
| # Destination file object (or stdout if no output file was supplied). | |
| destn:TextIO = sys.stdout | |
| # Set of file Path objects previously inlined (and to ignore if reencountering). | |
| found: Set[Path] = set() | |
| # Compiled regex Patern to handle the following type of file includes: | |
| # | |
| # #include "file" | |
| # #include "file" | |
| # # include "file" | |
| # #include "file" | |
| # #include "file" // comment | |
| # #include "file" // comment with quote " | |
| # | |
| # And all combinations of, as well as ignoring the following: | |
| # | |
| # #include <file> | |
| # //#include "file" | |
| # /*#include "file"*/ | |
| # | |
| # We don't try to catch errors since the compiler will do this (and the code is | |
| # expected to be valid before processing) and we don't care what follows the | |
| # file (whether it's a valid comment or not, since anything after the quoted | |
| # string is ignored) | |
| # | |
| include_regex: Pattern = re.compile(r'^\s*#\s*include\s*"(.+?)"') | |
| # Simple tests to prove include_regex's cases. | |
| # | |
| def test_match_include() -> bool: | |
| if (include_regex.match('#include "file"') and | |
| include_regex.match(' #include "file"') and | |
| include_regex.match('# include "file"') and | |
| include_regex.match('#include "file"') and | |
| include_regex.match('#include "file" // comment')): | |
| if (not include_regex.match('#include <file>') and | |
| not include_regex.match('//#include "file"') and | |
| not include_regex.match('/*#include "file"*/')): | |
| found = include_regex.match('#include "file" // "') | |
| if (found and found.group(1) == 'file'): | |
| print('#include match valid') | |
| return True | |
| return False | |
| # Compiled regex Patern to handle "#pragma once" in various formats: | |
| # | |
| # #pragma once | |
| # #pragma once | |
| # # pragma once | |
| # #pragma once | |
| # #pragma once // comment | |
| # | |
| # Ignoring commented versions, same as include_regex. | |
| # | |
| pragma_regex: Pattern = re.compile(r'^\s*#\s*pragma\s*once\s*') | |
| # Simple tests to prove pragma_regex's cases. | |
| # | |
| def text_match_pragma() -> bool: | |
| if (pragma_regex.match('#pragma once') and | |
| pragma_regex.match(' #pragma once') and | |
| pragma_regex.match('# pragma once') and | |
| pragma_regex.match('#pragma once') and | |
| pragma_regex.match('#pragma once // comment')): | |
| if (not pragma_regex.match('//#pragma once') and | |
| not pragma_regex.match('/*#pragma once*/')): | |
| print('#pragma once match valid') | |
| return True | |
| return False | |
| # Finds 'file'. First the list of 'root' paths are searched, followed by the | |
| # the currently processing file's 'parent' path, returning a valid Path in | |
| # canonical form. If no match is found None is returned. | |
| # | |
| def resolve_include(file: str, parent: Optional[Path] = None) -> Optional[Path]: | |
| for root in roots: | |
| found = root.joinpath(file).resolve() | |
| if (found.is_file()): | |
| return found | |
| if (parent): | |
| found = parent.joinpath(file).resolve(); | |
| else: | |
| found = Path(file) | |
| if (found.is_file()): | |
| return found | |
| return None | |
| # Helper to resolve lists of files. 'file_list' is passed in from the arguments | |
| # and each entry resolved to its canonical path (like any include entry, either | |
| # from the list of root paths or the owning file's 'parent', which in this case | |
| # is case is the input file). The results are stored in 'resolved'. | |
| # | |
| def resolve_excluded_files(file_list: Optional[List[str]], resolved: Set[Path], parent: Optional[Path] = None) -> None: | |
| if (file_list): | |
| for filename in file_list: | |
| found = resolve_include(filename, parent) | |
| if (found): | |
| resolved.add(found) | |
| else: | |
| error_line(f'Warning: excluded file not found: {filename}') | |
| # Writes 'line' to the open 'destn' (or stdout). | |
| # | |
| def write_line(line: str) -> None: | |
| print(line, file=destn) | |
| # Logs 'line' to stderr. This is also used for general notifications that we | |
| # don't want to go to stdout (so the source can be piped). | |
| # | |
| def error_line(line: Any) -> None: | |
| print(line, file=sys.stderr) | |
| # Inline the contents of 'file' (with any of its includes also inlined, etc.). | |
| # | |
| # Note: text encoding errors are ignored and replaced with ? when reading the | |
| # input files. This isn't ideal, but it's more than likely in the comments than | |
| # code and a) the text editor has probably also failed to read the same content, | |
| # and b) the compiler probably did too. | |
| # | |
| def add_file(file: Path, file_name: str = None) -> None: | |
| if (file.is_file()): | |
| if (not file_name): | |
| file_name = file.name | |
| error_line(f'Processing: {file_name}') | |
| with file.open('r', encoding='utf-8-sig', errors='replace') as opened: | |
| for line in opened: | |
| line = line.rstrip('\n') | |
| match_include = include_regex.match(line); | |
| if (match_include): | |
| # We have a quoted include directive so grab the file | |
| inc_name = match_include.group(1) | |
| resolved = resolve_include(inc_name, file.parent) | |
| if (resolved): | |
| if (resolved in excludes): | |
| # The file was excluded so error if the compiler uses it | |
| write_line(f'#error Using excluded file: {inc_name}') | |
| error_line(f'Excluding: {inc_name}') | |
| else: | |
| if (resolved not in found): | |
| # The file was not previously encountered | |
| found.add(resolved) | |
| if (resolved in keeps): | |
| # But the include was flagged to keep as included | |
| write_line(f'/**** *NOT* inlining {inc_name} ****/') | |
| write_line(line) | |
| error_line(f'Not inlining: {inc_name}') | |
| else: | |
| # The file was neither excluded nor seen before so inline it | |
| write_line(f'/**** start inlining {inc_name} ****/') | |
| add_file(resolved, inc_name) | |
| write_line(f'/**** ended inlining {inc_name} ****/') | |
| else: | |
| write_line(f'/**** skipping file: {inc_name} ****/') | |
| else: | |
| # The include file didn't resolve to a file | |
| write_line(f'#error Unable to find: {inc_name}') | |
| error_line(f'Error: Unable to find: {inc_name}') | |
| else: | |
| # Skip any 'pragma once' directives, otherwise write the source line | |
| if (keep_pragma or not pragma_regex.match(line)): | |
| write_line(line) | |
| else: | |
| error_line(f'Error: Invalid file: {file}') | |
| # Start here | |
| parser = argparse.ArgumentParser(description='Amalgamate Tool', epilog=f'example: {sys.argv[0]} -r ../my/path -r ../other/path -o out.c in.c') | |
| parser.add_argument('-r', '--root', action='append', type=Path, help='file root search path') | |
| parser.add_argument('-x', '--exclude', action='append', help='file to completely exclude from inlining') | |
| parser.add_argument('-k', '--keep', action='append', help='file to exclude from inlining but keep the include directive') | |
| parser.add_argument('-p', '--pragma', action='store_true', default=False, help='keep any "#pragma once" directives (removed by default)') | |
| parser.add_argument('-o', '--output', type=argparse.FileType('w'), help='output file (otherwise stdout)') | |
| parser.add_argument('input', type=Path, help='input file') | |
| args = parser.parse_args() | |
| # Fail early on an invalid input (and store it so we don't recurse) | |
| args.input = args.input.resolve(strict=True) | |
| found.add(args.input) | |
| # Resolve all of the root paths upfront (we'll halt here on invalid roots) | |
| if (args.root): | |
| for path in args.root: | |
| roots.add(path.resolve(strict=True)) | |
| # The remaining params: so resolve the excluded files and #pragma once directive | |
| resolve_excluded_files(args.exclude, excludes, args.input.parent) | |
| resolve_excluded_files(args.keep, keeps, args.input.parent) | |
| keep_pragma = args.pragma; | |
| # Then recursively process the input file | |
| try: | |
| if (args.output): | |
| destn = args.output | |
| add_file(args.input) | |
| finally: | |
| if (not destn): | |
| destn.close() |