diff --git a/SConscript b/SConscript index fe98247..f1daa3c 100644 --- a/SConscript +++ b/SConscript @@ -1,6 +1,6 @@ import copy -from dataclasses import dataclass +from dataclasses import dataclass, field import enum import glob import inspect @@ -12,8 +12,10 @@ import psutil import shutil import sys import time +from typing import Any import uuid +from SCons.Node import Node class TargetType(enum.Enum): PROGRAM = 0 @@ -41,6 +43,14 @@ class _Dependency: depdeps: list = [] cook_result: dict = {} +@dataclass +class _Module: + name: str + folder: str + description: str + cxx_namespace: str + targets: list['_Target'] = field(default_factory=list) + class _Target: name: str target_type: TargetType @@ -49,12 +59,7 @@ class _Target: kwargs: dict = {} dependencies: list = [] target = None - -@dataclass -class _Module: - name: str - description: str - cxx_namespace: str + module: _Module = None def _find_recipe(env: Environment, recipe_name: str): if recipe_name in env['SPP_RECIPES']: @@ -98,13 +103,17 @@ def _cook(env: Environment, recipe_name: str): def _normalize_module_path(env: Environment, path: str) -> str: module_root = env.Dir('#/private').abspath - return os.path.relpath(path, module_root) + try: + return os.path.relpath(path, module_root) + except ValueError: # may be thrown on Windows if the module is on a different drive than the project + return os.path.normpath(path) # just use the absolute path then def _module(env: Environment, file: str): folder = _normalize_module_path(env, env.File(file).dir.abspath) dirname = os.path.basename(folder) env.Append(SPP_MODULES = {folder: _Module( name=dirname, + folder=folder, description='', cxx_namespace=dirname )}) @@ -454,6 +463,13 @@ def _wrap_builder(builder, target_type: TargetType): target.args = args target.kwargs = kwargs target.dependencies = target_dependencies + module_folder = _normalize_module_path(env, env.Dir('.').abspath) + module = env['SPP_MODULES'].get(module_folder) + if module is None: + env.Warn(f'No module config found for target {target.name} at {module_folder}') + else: + target.module = module + module.targets.append(target) env.Append(SPP_TARGETS = [target]) if not target.dependencies: _build_target(target) @@ -582,7 +598,8 @@ def _generate_project(project_type: str) -> None: source_folder, target_folder = { 'clion': (os.path.join(_spp_dir.abspath, 'util', 'clion_project_template'), Dir('#.idea').abspath), - 'vscode': (os.path.join(_spp_dir.abspath, 'util', 'vscode_project_template'), Dir('#.vscode').abspath) + 'vscode': (os.path.join(_spp_dir.abspath, 'util', 'vscode_project_template'), Dir('#.vscode').abspath), + 'vs': (os.path.join(_spp_dir.abspath, 'util', 'vs_project_template'), Dir('#').abspath) }.get(project_type, (None, None)) if not source_folder: _error(None, 'Invalid project type option.') @@ -597,15 +614,18 @@ def _generate_project(project_type: str) -> None: except Exception as e: print(f'Error loading UUID cache: {e}') - def _generate_uuid(name: str = '') -> str: + def _generate_uuid(name: str = '', ms_style: bool = False) -> str: nonlocal save_uuid_cache if name and name in uuid_cache: - return uuid_cache[name] - new_uuid = str(uuid.uuid4()) - if name: - uuid_cache[name] = new_uuid - save_uuid_cache = True - return new_uuid + result = uuid_cache[name] + else: + result = str(uuid.uuid4()) + if name: + uuid_cache[name] = result + save_uuid_cache = True + if ms_style: + return f'{{{result.upper()}}}' + return result root_path = pathlib.Path(env.Dir('#').abspath) def _get_executables() -> list: @@ -619,7 +639,10 @@ def _generate_project(project_type: str) -> None: return str(exe_path) result.append({ 'name': target.name, - 'filename': _exe_path + 'filename': _exe_path, + 'target': target, + 'type': 'executable', + 'module': target.module }) return result def _get_libraries() -> list: @@ -633,7 +656,10 @@ def _generate_project(project_type: str) -> None: return str(lib_path) result.append({ 'name': target.name, - 'filename': _lib_path + 'filename': _lib_path, + 'target': target, + 'type': 'static_library', + 'module': target.module }) elif target.target_type == TargetType.SHARED_LIBRARY: trgt = _target_entry(target.kwargs['target']) @@ -643,40 +669,153 @@ def _generate_project(project_type: str) -> None: return str(lib_path) result.append({ 'name': target.name, - 'filename': _lib_path + 'filename': _lib_path, + 'target': target, + 'type': 'static_library', + 'module': target.module }) return result + def _get_modules() -> list: + result = [] + for folder, config in env['SPP_MODULES'].items(): + result.append({ + 'name': config.name, + 'private_folder': os.path.join('private', folder), + 'public_folder': os.path.join('public', folder), + 'description': config.description, + 'cxx_namespace': config.cxx_namespace + }) + return result def _escape_path(input: str) -> str: return input.replace('\\', '\\\\') + def _strip_path_prefix(path: str, skip_eles: int) -> str: + for _ in range(skip_eles): + pos = path.find(os.sep) + if pos < 0: + return '' + path = path[pos+1:] + return path + + + def _folder_list(file_list: list[str], skip_eles: int = 0) -> list[str]: + result = {} + for file in file_list: + folder = os.path.dirname(file) + folder = _strip_path_prefix(folder, skip_eles) + if folder == '': + continue + while True: + result[folder] = True + # also add all parents + sep_pos = folder.rfind(os.sep) + if sep_pos < 0: + break + folder = folder[0:sep_pos] + return list(result.keys()) + + + def _get_sources(target_dict: dict) -> list[str]: + target : _Target = target_dict['target'] + sources = target.kwargs.get('source') + return [str(pathlib.Path(source.abspath).relative_to(root_path)) for source in sources] + + def _get_headers(folder: str) -> list[str]: + result = [] + for root, _, files in os.walk(folder): + for file in files: + _, ext = os.path.splitext(file) + if ext in ('.h', '.hpp', '.inl', '.hxx'): + result.append(os.path.join(root, file)) + return result + + def _get_target_property(build_type: str, target: str, path: str) -> Any: + import subprocess + output = subprocess.check_output((shutil.which('scons'), '--silent', f'--build_type={build_type}', '--dump=targets', '--dump_format=json', f'--dump_path={target}/{path}'), text=True).strip() + return json.loads(output) + + + executables = _get_executables() + libraries = _get_libraries() + modules = _get_modules() + jinja_env = jinja2.Environment() jinja_env.globals['generate_uuid'] = _generate_uuid + jinja_env.globals['get_sources'] = _get_sources + jinja_env.globals['get_headers'] = _get_headers + jinja_env.globals['get_target_property'] = _get_target_property jinja_env.globals['project'] = { 'name': env.Dir('#').name, - 'executables': _get_executables(), - 'libraries': _get_libraries(), - 'build_types': ['debug', 'release_debug', 'release', 'profile'] + 'executables': executables, + 'libraries': libraries, + 'modules': modules, + 'build_types': ['debug', 'release_debug', 'release', 'profile'], + 'cxx_standard': env['CXX_STANDARD'] } jinja_env.globals['scons_exe'] = shutil.which('scons') jinja_env.globals['nproc'] = multiprocessing.cpu_count() jinja_env.filters['escape_path'] = _escape_path + jinja_env.filters['strip_path_prefix'] = _strip_path_prefix + jinja_env.filters['folder_list'] = _folder_list + jinja_env.filters['basename'] = os.path.basename + jinja_env.filters['dirname'] = os.path.dirname source_path = pathlib.Path(source_folder) target_path = pathlib.Path(target_folder) + config = {} + config_file = source_path / 'template.json' + if config_file.exists(): + with config_file.open('r') as f: + config = json.load(f) + files_config = config.get('files', {}) + for source_file in source_path.rglob('*'): - if source_file.is_file(): - target_file = target_path / (source_file.relative_to(source_path)) + if source_file == config_file: + continue + if not source_file.is_file(): + continue + source_file_relative = source_file.relative_to(source_path) + file_config = files_config.get(str(source_file_relative).replace('\\', '/'), {}) + one_per = file_config.get('one_per', 'project') + + def generate_file_once() -> None: + is_jinja = (source_file.suffix == '.jinja') + if 'rename_to' in file_config: + new_filename = jinja_env.from_string(file_config['rename_to']).render() + target_file = target_path / new_filename + else: + target_file = target_path / source_file_relative + if is_jinja: + target_file = target_file.with_suffix('') target_file.parent.mkdir(parents=True, exist_ok=True) - if source_file.suffix != '.jinja': + if not is_jinja: shutil.copyfile(source_file, target_file) - continue + return with source_file.open('r') as f: - templ = jinja_env.from_string(f.read()) - target_file = target_file.with_suffix('') + try: + templ = jinja_env.from_string(f.read()) + except jinja2.TemplateSyntaxError as e: + e.filename = str(source_file) + raise e with target_file.open('w') as f: f.write(templ.render()) + try: + if one_per == 'project': + generate_file_once() + elif one_per == 'target': + for executable in executables: + jinja_env.globals['target'] = executable + generate_file_once() + for library in libraries: + jinja_env.globals['target'] = library + generate_file_once() + else: + raise ValueError(f'invalid value for "one_per": {one_per}') + except jinja2.TemplateSyntaxError as e: + env.Error(f'Jinja syntax error at {e.filename}:{e.lineno}: {e.message}') + Exit(1) if save_uuid_cache: try: @@ -691,7 +830,8 @@ def _dump() -> None: dump_name = { 'env': 'Environment', 'config': 'Configuration', - 'modules': 'Modules' + 'modules': 'Modules', + 'targets': 'Targets' }[dump] return '\n'.join(( @@ -703,15 +843,66 @@ def _dump() -> None: class _Encoder(json.JSONEncoder): def default(self, o) -> dict: if isinstance(o, object): + if hasattr(o, '__iter__'): + return list(o) + elif isinstance(o, Node): + return o.abspath return o.__dict__ return super().default(o) return json.dumps(data, cls=_Encoder) + def _apply_path(data: Any, path: str) -> Any: + for part in path.split('/'): + if isinstance(data, dict): + if part not in data: + _error(f'Invalid path specified. No key {part} in dict {data}.') + Exit(1) + data = data[part] + elif isinstance(data, list): + try: + part = int(part) + except ValueError: + _error(f'Invalid path specified. {part} is not a valid list index.') + Exit(1) + if part < 0 or part >= len(data): + _error(f'Invalid path specified. {part} is out of list range.') + Exit(1) + data = data[part] + elif isinstance(data, object): + data = data.__dict__ + if part not in data: + _error(f'Invalid path specified. No attribute {part} in object {data}.') + Exit(1) + data = data[part] + else: + _error(f'Invalid path specified. {data} has no properties.') + Exit(1) + return data + def _targets() -> dict: + result = {} + for target in env['SPP_TARGETS']: + kwargs = target.kwargs.copy() + for dependency in target.dependencies: + _inject_dependency(dependency, kwargs) + result[target.name] = { + 'target_type': target.target_type.name, + 'args': target.args, + # 'kwargs': kwargs, <- circular dependency here and the json encoder doesn't like that + 'CPPDEFINES': kwargs.get('CPPDEFINES', env['CPPDEFINES']), + 'CPPPATH': kwargs.get('CPPPATH', env['CPPPATH']) + } + return result data = { 'env': env.Dictionary, 'config': lambda: config, - 'modules': lambda: env['SPP_MODULES'] + 'modules': lambda: env['SPP_MODULES'], + 'targets': _targets }[dump]() + + global dump_path + dump_path = dump_path.strip() + if dump_path != '': + data = _apply_path(data, dump_path) dump_fn = { 'text': _dump_as_text, 'json': _dump_as_json @@ -805,7 +996,7 @@ AddOption( '--dump', dest = 'dump', type = 'choice', - choices = ('env', 'config', 'modules'), + choices = ('env', 'config', 'modules', 'targets'), nargs = 1, action = 'store' ) @@ -820,11 +1011,19 @@ AddOption( default = 'text' ) +AddOption( + '--dump_path', + dest = 'dump_path', + nargs = 1, + action = 'store', + default = '' +) + AddOption( '--generate_project', dest = 'generate_project', type = 'choice', - choices = ('clion', 'vscode'), + choices = ('clion', 'vscode', 'vs'), nargs = 1, action = 'store' ) @@ -841,6 +1040,7 @@ update_repositories = GetOption('update_repositories') disable_auto_update = GetOption('disable_auto_update') dump = GetOption('dump') dump_format = GetOption('dump_format') +dump_path = GetOption('dump_path') generate_project = GetOption('generate_project') default_CC = { diff --git a/contrib/vs/spp.targets b/contrib/vs/spp.targets new file mode 100644 index 0000000..2aa1b90 --- /dev/null +++ b/contrib/vs/spp.targets @@ -0,0 +1,53 @@ + + + .sln + C++ + .cpp + + + + $([System.IO.Path]::GetFileName('$(TargetPath)')) + $([System.IO.Path]::GetDirectoryName('$(TargetPath)')) + $(TargetDir) + $(TargetPath) + + scons + $([System.Environment]::ProcessorCount) + debug + executable + + $(OutputPath)\ + $(SolutionDir)cache\msbuild\ + + + + + + + + + + + + + + + + + + + <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> + + + + + diff --git a/util/vs_project_template/solution.sln.jinja b/util/vs_project_template/solution.sln.jinja new file mode 100644 index 0000000..2629268 --- /dev/null +++ b/util/vs_project_template/solution.sln.jinja @@ -0,0 +1,38 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +{%- for executable in project.executables %} +Project("{{ generate_uuid(project.name, True) }}") = "{{ executable.name }}", "vs_project_files\{{ executable.name }}.vcxproj", ""{{ generate_uuid('target_' + executable.name, True) }}"" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{{ generate_uuid('solution_items', True) }}" + ProjectSection(SolutionItems) = preProject + SConstruct = SConstruct + EndProjectSection +EndProject +{%- endfor %} +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + {%- for build_type in project.build_types %} + {%- set build_type_name = build_type | capitalize %} + {{ build_type_name }}|x64 = {{ build_type_name }}|x64 + {%- endfor %} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {%- for executable in project.executables %} + {%- for build_type in project.build_types %} + {%- set build_type_name = build_type | capitalize %} + {{ generate_uuid('target_' + executable.name, True) }}.{{ build_type_name }}|x64.ActiveCfg = {{ build_type_name }}|x64 + {{ generate_uuid('target_' + executable.name, True) }}.{{ build_type_name }}|x64.Build.0 = {{ build_type_name }}|x64 + {%- endfor %} + {%- endfor %} + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {{ generate_uuid("solution", True) }} + EndGlobalSection +EndGlobal + diff --git a/util/vs_project_template/template.json b/util/vs_project_template/template.json new file mode 100644 index 0000000..adf50bb --- /dev/null +++ b/util/vs_project_template/template.json @@ -0,0 +1,15 @@ +{ + "files": { + "solution.sln.jinja": { + "rename_to": "{{ project.name }}.sln" + }, + "vs_project_files/target.vcxproj.jinja": { + "one_per": "target", + "rename_to": "vs_project_files/{{ target.name }}.vcxproj" + }, + "vs_project_files/target.vcxproj.filters.jinja": { + "one_per": "target", + "rename_to": "vs_project_files/{{ target.name }}.vcxproj.filters" + } + } +} diff --git a/util/vs_project_template/vs_project_files/target.vcxproj.filters.jinja b/util/vs_project_template/vs_project_files/target.vcxproj.filters.jinja new file mode 100644 index 0000000..fe40046 --- /dev/null +++ b/util/vs_project_template/vs_project_files/target.vcxproj.filters.jinja @@ -0,0 +1,73 @@ +{%- set source_files = get_sources(target) -%} +{%- set private_headers = get_headers('private\\' + target.module.folder) -%} +{%- set public_headers = get_headers('public\\' + target.module.folder) -%} + + + + + {{ generate_uuid('filter_sources_' + target.name, True) }} + + {%- for folder in source_files | folder_list(2) | sort %} + + {{ generate_uuid('filter_sources_' + target.name + '_' + folder, True) }} + + {%- endfor %} + {%- if public_headers | length > 0 %} + + {{ generate_uuid('filter_public_headers_' + target.name, True) }} + + {%- for folder in public_headers | folder_list(2) | sort %} + + {{ generate_uuid('filter_public_headers_' + target.name + '_' + folder, True) }} + + {%- endfor %} + {%- endif %} + {%- if private_headers | length > 0 %} + + {{ generate_uuid('filter_private_headers_' + target.name, True) }} + + {%- for folder in private_headers | folder_list(2) | sort %} + + {{ generate_uuid('filter_private_headers_' + target.name + '_' + folder, True) }} + + {%- endfor %} + {%- endif %} + + + {%- for source_file in source_files %} + + {%- set path = source_file | strip_path_prefix(2) | dirname -%} + {%- if path %} + Source Files\{{ path }} + {%- else %} + Source Files + {%- endif %} + + {%- endfor %} + + + {%- for header_file in public_headers %} + + {%- set path = header_file | strip_path_prefix(2) | dirname -%} + {%- if path %} + Public Header Files\{{ path }} + {%- else %} + Public Header Files + {%- endif %} + + {%- endfor %} + {%- for header_file in private_headers %} + + {%- set path = header_file | strip_path_prefix(2) | dirname -%} + {%- if path %} + Private Header Files\{{ path }} + {%- else %} + Private Header Files + {%- endif %} + + {%- endfor %} + + + + + \ No newline at end of file diff --git a/util/vs_project_template/vs_project_files/target.vcxproj.jinja b/util/vs_project_template/vs_project_files/target.vcxproj.jinja new file mode 100644 index 0000000..22f0e27 --- /dev/null +++ b/util/vs_project_template/vs_project_files/target.vcxproj.jinja @@ -0,0 +1,67 @@ +{%- set ms_cxx_standard = { + 'c++14': 'stdcpp14', + 'c++17': 'stdcpp17', + 'c++20': 'stdcpp20', + 'c++23': 'stdcpplatest', + 'c++26': 'stdcpplatest'}[project.cxx_standard] | default('stdcpp14') +-%} + + + + {%- for build_type in project.build_types %} + {% set build_type_name = build_type | capitalize -%} + + {{ build_type_name }} + x64 + + {%- endfor %} + + + Debug + {{ generate_uuid('target_' + target.name, True) }} + {{ target.name }} + {{ scons_exe }} + + {%- for build_type in project.build_types %} + {% set build_type_name = build_type | capitalize -%} + + $(SolutionDir){{ target.filename(build_type) }} + {{ build_type }} + {{ target.type }} + + {%- endfor %} + + + Makefile + + + + {%- for source_file in get_sources(target) %} + + {%- endfor %} + + + {%- for header_file in get_headers('private\\' + target.module.folder) %} + + {%- endfor %} + {%- for header_file in get_headers('public\\' + target.module.folder) %} + + {%- endfor %} + + + + + {%- for build_type in project.build_types %} + {% set build_type_name = build_type | capitalize -%} + + + {{ get_target_property(build_type, target.name, 'CPPDEFINES') | join(';') }};%(PreprocessorDefinitions); + {{ build_type != 'release' and 'true' or 'false' }} + {{ get_target_property(build_type, target.name, 'CPPPATH') | join(';') }};%(AdditionalIncludeDirectories) + false + {{ ms_cxx_standard }} + + + {%- endfor %} + + \ No newline at end of file diff --git a/util/vscode_project_template/launch.json.jinja b/util/vscode_project_template/launch.json.jinja index 08ca074..89b385f 100644 --- a/util/vscode_project_template/launch.json.jinja +++ b/util/vscode_project_template/launch.json.jinja @@ -1,8 +1,8 @@ { "configurations": [ - {% for executable in project.executables %} - {% for build_type in project.build_types %} - {% set build_type_name = build_type | capitalize -%} + {%- for executable in project.executables -%} + {%- for build_type in project.build_types -%} + {%- set build_type_name = build_type | capitalize %} { "name": "{{ executable.name }} ({{ build_type | capitalize }})", "type": "cppvsdbg", @@ -12,9 +12,10 @@ "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], - "console": "integratedTerminal" - } - {% endfor %} - {% endfor %} + "console": "integratedTerminal", + "preLaunchTask": "{{ executable.name }} {{ build_type_name }}" + }, + {%- endfor %} + {%- endfor %} ] } \ No newline at end of file