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