save progress
This commit is contained in:
381
tools/slntools/lib/sln_writer.py
Normal file
381
tools/slntools/lib/sln_writer.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Solution file (.sln) generation utilities.
|
||||
|
||||
Provides functions to:
|
||||
- Build solution folder hierarchy from projects
|
||||
- Generate complete .sln file content
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .csproj_parser import get_deterministic_guid
|
||||
from .models import (
|
||||
BYPASS_MARKER,
|
||||
CSHARP_PROJECT_TYPE_GUID,
|
||||
SOLUTION_FOLDER_TYPE_GUID,
|
||||
CsprojProject,
|
||||
SolutionFolder,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Solution file header
|
||||
SOLUTION_HEADER = """\
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
"""
|
||||
|
||||
|
||||
def build_folder_hierarchy(
|
||||
projects: list[CsprojProject],
|
||||
base_dir: Path,
|
||||
prefix: str = "",
|
||||
) -> dict[str, SolutionFolder]:
|
||||
"""
|
||||
Build solution folder hierarchy from project paths.
|
||||
|
||||
Creates nested folders matching the physical directory structure.
|
||||
|
||||
Args:
|
||||
projects: List of projects to organize
|
||||
base_dir: Base directory for calculating relative paths
|
||||
prefix: Optional prefix for folder paths (e.g., "__External")
|
||||
|
||||
Returns:
|
||||
Dictionary mapping folder path to SolutionFolder object
|
||||
"""
|
||||
folders: dict[str, SolutionFolder] = {}
|
||||
base_dir = base_dir.resolve()
|
||||
|
||||
for project in projects:
|
||||
try:
|
||||
rel_path = project.path.parent.relative_to(base_dir)
|
||||
except ValueError:
|
||||
# Project outside base_dir - skip folder creation
|
||||
continue
|
||||
|
||||
parts = list(rel_path.parts)
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
# Add prefix if specified
|
||||
if prefix:
|
||||
parts = [prefix] + list(parts)
|
||||
|
||||
# Create folders for each level
|
||||
current_path = ""
|
||||
parent_guid: Optional[str] = None
|
||||
|
||||
for part in parts:
|
||||
if current_path:
|
||||
current_path = f"{current_path}/{part}"
|
||||
else:
|
||||
current_path = part
|
||||
|
||||
if current_path not in folders:
|
||||
folder_guid = get_deterministic_guid(
|
||||
Path(current_path), Path("")
|
||||
)
|
||||
folders[current_path] = SolutionFolder(
|
||||
name=part,
|
||||
guid=folder_guid,
|
||||
path=current_path,
|
||||
parent_guid=parent_guid,
|
||||
)
|
||||
|
||||
parent_guid = folders[current_path].guid
|
||||
|
||||
# Assign project to its folder
|
||||
if current_path in folders:
|
||||
folders[current_path].projects.append(project)
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def build_external_folder_hierarchy(
|
||||
external_groups: dict[str, list[CsprojProject]],
|
||||
src_root: Path,
|
||||
) -> dict[str, SolutionFolder]:
|
||||
"""
|
||||
Build folder hierarchy for external dependencies.
|
||||
|
||||
Organizes external projects under __External/<Source>/<Path>.
|
||||
|
||||
Args:
|
||||
external_groups: Dictionary mapping source category to projects
|
||||
src_root: Root of the src/ directory
|
||||
|
||||
Returns:
|
||||
Dictionary mapping folder path to SolutionFolder object
|
||||
"""
|
||||
folders: dict[str, SolutionFolder] = {}
|
||||
src_root = src_root.resolve()
|
||||
|
||||
# Create __External root folder
|
||||
external_root_path = "__External"
|
||||
external_root_guid = get_deterministic_guid(Path(external_root_path), Path(""))
|
||||
folders[external_root_path] = SolutionFolder(
|
||||
name="__External",
|
||||
guid=external_root_guid,
|
||||
path=external_root_path,
|
||||
parent_guid=None,
|
||||
)
|
||||
|
||||
for category, projects in sorted(external_groups.items()):
|
||||
if not projects:
|
||||
continue
|
||||
|
||||
# Create category folder (e.g., __External/__Libraries, __External/Authority)
|
||||
category_path = f"{external_root_path}/{category}"
|
||||
category_guid = get_deterministic_guid(Path(category_path), Path(""))
|
||||
|
||||
if category_path not in folders:
|
||||
folders[category_path] = SolutionFolder(
|
||||
name=category,
|
||||
guid=category_guid,
|
||||
path=category_path,
|
||||
parent_guid=external_root_guid,
|
||||
)
|
||||
|
||||
# For each project, create intermediate folders based on path within source
|
||||
for project in projects:
|
||||
try:
|
||||
if category == "__Libraries":
|
||||
# Path relative to src/__Libraries/
|
||||
lib_root = src_root / "__Libraries"
|
||||
rel_path = project.path.parent.relative_to(lib_root)
|
||||
else:
|
||||
# Path relative to src/<Module>/
|
||||
module_root = src_root / category
|
||||
rel_path = project.path.parent.relative_to(module_root)
|
||||
except ValueError:
|
||||
# Just put directly in category folder
|
||||
folders[category_path].projects.append(project)
|
||||
continue
|
||||
|
||||
parts = list(rel_path.parts)
|
||||
if not parts:
|
||||
folders[category_path].projects.append(project)
|
||||
continue
|
||||
|
||||
# Create intermediate folders
|
||||
current_path = category_path
|
||||
parent_guid = category_guid
|
||||
|
||||
for part in parts:
|
||||
current_path = f"{current_path}/{part}"
|
||||
|
||||
if current_path not in folders:
|
||||
folder_guid = get_deterministic_guid(Path(current_path), Path(""))
|
||||
folders[current_path] = SolutionFolder(
|
||||
name=part,
|
||||
guid=folder_guid,
|
||||
path=current_path,
|
||||
parent_guid=parent_guid,
|
||||
)
|
||||
|
||||
parent_guid = folders[current_path].guid
|
||||
|
||||
# Assign project to deepest folder
|
||||
folders[current_path].projects.append(project)
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def generate_solution_content(
|
||||
sln_path: Path,
|
||||
projects: list[CsprojProject],
|
||||
folders: dict[str, SolutionFolder],
|
||||
external_folders: Optional[dict[str, SolutionFolder]] = None,
|
||||
add_bypass_marker: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Generate complete .sln file content.
|
||||
|
||||
Args:
|
||||
sln_path: Path where the solution will be written (for relative paths)
|
||||
projects: List of internal projects
|
||||
folders: Internal folder hierarchy
|
||||
external_folders: External dependency folders (optional)
|
||||
add_bypass_marker: Whether to add the bypass marker comment
|
||||
|
||||
Returns:
|
||||
Complete .sln file content as string
|
||||
"""
|
||||
lines: list[str] = []
|
||||
sln_dir = sln_path.parent.resolve()
|
||||
|
||||
# Add header
|
||||
if add_bypass_marker:
|
||||
lines.append(BYPASS_MARKER)
|
||||
lines.append("")
|
||||
lines.append(SOLUTION_HEADER.rstrip())
|
||||
|
||||
# Merge folders
|
||||
all_folders = dict(folders)
|
||||
if external_folders:
|
||||
all_folders.update(external_folders)
|
||||
|
||||
# Collect all projects (internal + external from folders)
|
||||
all_projects: list[CsprojProject] = list(projects)
|
||||
project_to_folder: dict[Path, str] = {}
|
||||
|
||||
for folder_path, folder in all_folders.items():
|
||||
for proj in folder.projects:
|
||||
if proj not in all_projects:
|
||||
all_projects.append(proj)
|
||||
project_to_folder[proj.path] = folder_path
|
||||
|
||||
# Write solution folder entries
|
||||
for folder_path in sorted(all_folders.keys()):
|
||||
folder = all_folders[folder_path]
|
||||
lines.append(
|
||||
f'Project("{{{SOLUTION_FOLDER_TYPE_GUID}}}") = "{folder.name}", "{folder.name}", "{{{folder.guid}}}"'
|
||||
)
|
||||
lines.append("EndProject")
|
||||
|
||||
# Write project entries
|
||||
for project in sorted(all_projects, key=lambda p: p.name):
|
||||
rel_path = _get_relative_path(sln_dir, project.path)
|
||||
lines.append(
|
||||
f'Project("{{{CSHARP_PROJECT_TYPE_GUID}}}") = "{project.name}", "{rel_path}", "{{{project.guid}}}"'
|
||||
)
|
||||
lines.append("EndProject")
|
||||
|
||||
# Write Global section
|
||||
lines.append("Global")
|
||||
|
||||
# SolutionConfigurationPlatforms
|
||||
lines.append("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution")
|
||||
lines.append("\t\tDebug|Any CPU = Debug|Any CPU")
|
||||
lines.append("\t\tRelease|Any CPU = Release|Any CPU")
|
||||
lines.append("\tEndGlobalSection")
|
||||
|
||||
# ProjectConfigurationPlatforms
|
||||
lines.append("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution")
|
||||
for project in sorted(all_projects, key=lambda p: p.name):
|
||||
guid = project.guid
|
||||
lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU")
|
||||
lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.Build.0 = Debug|Any CPU")
|
||||
lines.append(f"\t\t{{{guid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU")
|
||||
lines.append(f"\t\t{{{guid}}}.Release|Any CPU.Build.0 = Release|Any CPU")
|
||||
lines.append("\tEndGlobalSection")
|
||||
|
||||
# SolutionProperties
|
||||
lines.append("\tGlobalSection(SolutionProperties) = preSolution")
|
||||
lines.append("\t\tHideSolutionNode = FALSE")
|
||||
lines.append("\tEndGlobalSection")
|
||||
|
||||
# NestedProjects - assign folders and projects to parent folders
|
||||
lines.append("\tGlobalSection(NestedProjects) = preSolution")
|
||||
|
||||
# Nest folders under their parents
|
||||
for folder_path in sorted(all_folders.keys()):
|
||||
folder = all_folders[folder_path]
|
||||
if folder.parent_guid:
|
||||
lines.append(f"\t\t{{{folder.guid}}} = {{{folder.parent_guid}}}")
|
||||
|
||||
# Nest projects under their folders
|
||||
for project in sorted(all_projects, key=lambda p: p.name):
|
||||
if project.path in project_to_folder:
|
||||
folder_path = project_to_folder[project.path]
|
||||
folder = all_folders[folder_path]
|
||||
lines.append(f"\t\t{{{project.guid}}} = {{{folder.guid}}}")
|
||||
|
||||
lines.append("\tEndGlobalSection")
|
||||
|
||||
# ExtensibilityGlobals (required by VS)
|
||||
lines.append("\tGlobalSection(ExtensibilityGlobals) = postSolution")
|
||||
# Generate a solution GUID
|
||||
sln_guid = get_deterministic_guid(sln_path, sln_path.parent.parent)
|
||||
lines.append(f"\t\tSolutionGuid = {{{sln_guid}}}")
|
||||
lines.append("\tEndGlobalSection")
|
||||
|
||||
lines.append("EndGlobal")
|
||||
lines.append("") # Trailing newline
|
||||
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
def _get_relative_path(from_dir: Path, to_path: Path) -> str:
|
||||
"""
|
||||
Get relative path from directory to file, using backslashes.
|
||||
|
||||
Args:
|
||||
from_dir: Directory to calculate from
|
||||
to_path: Target path
|
||||
|
||||
Returns:
|
||||
Relative path with backslashes (Windows format for .sln)
|
||||
"""
|
||||
try:
|
||||
rel = to_path.relative_to(from_dir)
|
||||
return str(rel).replace("/", "\\")
|
||||
except ValueError:
|
||||
# Different drive or not relative - use absolute with backslashes
|
||||
return str(to_path).replace("/", "\\")
|
||||
|
||||
|
||||
def has_bypass_marker(sln_path: Path) -> bool:
|
||||
"""
|
||||
Check if a solution file has the bypass marker.
|
||||
|
||||
Args:
|
||||
sln_path: Path to the solution file
|
||||
|
||||
Returns:
|
||||
True if the bypass marker is found in the first 10 lines
|
||||
"""
|
||||
if not sln_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(sln_path, "r", encoding="utf-8-sig") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= 10:
|
||||
break
|
||||
if BYPASS_MARKER in line:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read solution file {sln_path}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def write_solution_file(
|
||||
sln_path: Path,
|
||||
content: str,
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Write solution content to file.
|
||||
|
||||
Args:
|
||||
sln_path: Path to write to
|
||||
content: Solution file content
|
||||
dry_run: If True, don't actually write
|
||||
|
||||
Returns:
|
||||
True if successful (or would be successful in dry run)
|
||||
"""
|
||||
if dry_run:
|
||||
logger.info(f"Would write solution to: {sln_path}")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
sln_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write with UTF-8 BOM and CRLF line endings
|
||||
with open(sln_path, "w", encoding="utf-8-sig", newline="\r\n") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"Wrote solution to: {sln_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write solution {sln_path}: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user