Skip to main content

unity_solution_generator/
solution_generator.rs

1//! Renders `.csproj` / `.sln` / `Directory.Build.props` for a platform+config variant.
2//! Single entry point driven by `csproj.lock`.
3
4use std::collections::{HashMap, HashSet};
5
6use rayon::prelude::*;
7
8use crate::error::Result;
9use crate::io::{create_dir_all, write_file_if_changed};
10use crate::lockfile::{DllRef, Lockfile, RefCategory};
11use crate::paths::{DEFAULT_GENERATOR_ROOT, join_path, resolve_real_path};
12use crate::project_scanner::{AsmDefRecord, ProjectCategory, ProjectScanner, ScanResult};
13use crate::xml::{deterministic_guid, xml_escape};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BuildPlatform {
17    Ios,
18    Android,
19    Osx,
20}
21
22impl BuildPlatform {
23    pub fn parse(s: &str) -> Option<Self> {
24        Some(match s {
25            "ios" => BuildPlatform::Ios,
26            "android" => BuildPlatform::Android,
27            "osx" => BuildPlatform::Osx,
28            _ => return None,
29        })
30    }
31
32    pub fn raw(self) -> &'static str {
33        match self {
34            BuildPlatform::Ios => "ios",
35            BuildPlatform::Android => "android",
36            BuildPlatform::Osx => "osx",
37        }
38    }
39
40    /// Unity's `includePlatforms` value. Used for platform-filter matching.
41    pub fn unity_platform_name(self) -> &'static str {
42        match self {
43            BuildPlatform::Ios => "iOS",
44            BuildPlatform::Android => "Android",
45            BuildPlatform::Osx => "macOSStandalone",
46        }
47    }
48
49    pub fn platform_defines(self) -> &'static [&'static str] {
50        match self {
51            BuildPlatform::Ios => &["UNITY_IOS", "UNITY_IPHONE"],
52            BuildPlatform::Android => &["UNITY_ANDROID"],
53            BuildPlatform::Osx => &["UNITY_STANDALONE", "UNITY_STANDALONE_OSX"],
54        }
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum BuildConfig {
60    Editor,
61    Dev,
62    Prod,
63}
64
65impl BuildConfig {
66    pub fn parse(s: &str) -> Option<Self> {
67        Some(match s {
68            "editor" => BuildConfig::Editor,
69            "dev" => BuildConfig::Dev,
70            "prod" => BuildConfig::Prod,
71            _ => return None,
72        })
73    }
74
75    pub fn raw(self) -> &'static str {
76        match self {
77            BuildConfig::Editor => "editor",
78            BuildConfig::Dev => "dev",
79            BuildConfig::Prod => "prod",
80        }
81    }
82}
83
84const EDITOR_DEFINES: &[&str] = &["UNITY_EDITOR", "UNITY_EDITOR_64", "UNITY_EDITOR_OSX"];
85const DEBUG_DEFINES: &[&str] = &["DEBUG", "TRACE", "UNITY_ASSERTIONS"];
86
87#[derive(Debug, Clone)]
88pub struct GenerateOptions {
89    pub project_root: String,
90    pub generator_root: String,
91    pub verbose: bool,
92    /// `None` → default variant subdir; `Some(".")` → project root; `Some("rel/path")` otherwise.
93    pub output_dir: Option<String>,
94    pub extra_refs: Vec<DllRef>,
95    pub platform: BuildPlatform,
96    pub build_config: BuildConfig,
97}
98
99impl GenerateOptions {
100    pub fn new(project_root: impl Into<String>, platform: BuildPlatform) -> Self {
101        Self {
102            project_root: project_root.into(),
103            generator_root: DEFAULT_GENERATOR_ROOT.to_string(),
104            verbose: false,
105            output_dir: None,
106            extra_refs: Vec::new(),
107            platform,
108            build_config: BuildConfig::Prod,
109        }
110    }
111
112    pub fn with_generator_root(mut self, generator_root: impl Into<String>) -> Self {
113        self.generator_root = generator_root.into();
114        self
115    }
116
117    pub fn with_build_config(mut self, build_config: BuildConfig) -> Self {
118        self.build_config = build_config;
119        self
120    }
121
122    pub fn with_output_dir(mut self, output_dir: Option<&str>) -> Self {
123        self.output_dir = output_dir.map(|d| {
124            let trimmed = d.trim_end_matches('/');
125            if trimmed.is_empty() {
126                ".".to_string()
127            } else {
128                trimmed.to_string()
129            }
130        });
131        self
132    }
133
134    pub fn with_extra_refs(mut self, extra_refs: Vec<DllRef>) -> Self {
135        self.extra_refs = extra_refs;
136        self
137    }
138
139    pub fn with_verbose(mut self, verbose: bool) -> Self {
140        self.verbose = verbose;
141        self
142    }
143}
144
145#[derive(Debug, Clone)]
146pub struct GenerateResult {
147    pub warnings: Vec<String>,
148    pub variant_csprojs: Vec<String>,
149    pub variant_sln_path: String,
150}
151
152#[derive(Debug, Clone)]
153struct ProjectInfo {
154    name: String,
155    guid: String,
156}
157
158impl ProjectInfo {
159    fn csproj_path(&self) -> String {
160        format!("{}.csproj", self.name)
161    }
162}
163
164struct GenerationContext {
165    project_root: String,
166    scan: ScanResult,
167    project_by_name: HashMap<String, ProjectInfo>,
168    patterns_by_project: HashMap<String, Vec<String>>,
169    included_projects: Vec<ProjectInfo>,
170    non_runtime_names: HashSet<String>,
171    variant_dir: String,
172    result_prefix: String,
173    warnings: Vec<String>,
174}
175
176fn build_context(
177    options: &GenerateOptions,
178    project_root: String,
179    projects: Vec<ProjectInfo>,
180    scan: ScanResult,
181) -> GenerationContext {
182    let project_by_name: HashMap<String, ProjectInfo> = projects
183        .iter()
184        .map(|p| (p.name.clone(), p.clone()))
185        .collect();
186
187    let mut warnings: Vec<String> = Vec::new();
188    if !scan.unresolved_dirs.is_empty() {
189        warnings.push(format!(
190            "Unresolved source directories: {}",
191            scan.unresolved_dirs.len()
192        ));
193    }
194    if options.verbose {
195        for d in scan.unresolved_dirs.iter().take(20) {
196            warnings.push(format!("Unresolved: {}/", d));
197        }
198    }
199
200    let depth: usize = if let Some(out) = options.output_dir.as_deref() {
201        if out == "." {
202            0
203        } else {
204            out.split('/').filter(|s| !s.is_empty()).count()
205        }
206    } else {
207        // generator_root path components + 1 for the variant subdir
208        options
209            .generator_root
210            .split('/')
211            .filter(|s| !s.is_empty())
212            .count()
213            + 1
214    };
215    let variant_prefix: String = "../".repeat(depth);
216
217    let mut patterns_by_project: HashMap<String, Vec<String>> = HashMap::new();
218    for project in &projects {
219        let mut dirs: Vec<String> = scan
220            .dirs_by_project
221            .get(&project.name)
222            .cloned()
223            .unwrap_or_default();
224        dirs.sort();
225        let pats: Vec<String> = dirs
226            .iter()
227            .map(|d| {
228                if d.is_empty() {
229                    format!("{}*.cs", variant_prefix)
230                } else {
231                    format!("{}{}/*.cs", variant_prefix, d)
232                }
233            })
234            .collect();
235        patterns_by_project.insert(project.name.clone(), pats);
236    }
237
238    let is_editor = options.build_config == BuildConfig::Editor;
239    let mut included_projects: Vec<ProjectInfo> = Vec::new();
240    let mut non_runtime_names: HashSet<String> = HashSet::new();
241    if is_editor {
242        included_projects = projects.clone();
243    } else {
244        for project in &projects {
245            let (category, matches_platform) = match scan.asm_def_by_name.get(&project.name) {
246                Some(asm) => {
247                    let platforms: Vec<&str> = asm
248                        .include_platforms
249                        .iter()
250                        .filter(|p| p.as_str() != "Editor")
251                        .map(String::as_str)
252                        .collect();
253                    let matches =
254                        platforms.is_empty() || platforms.contains(&options.platform.unity_platform_name());
255                    (asm.category, matches)
256                }
257                None => (ProjectCategory::Runtime, true),
258            };
259            if category == ProjectCategory::Runtime && matches_platform {
260                included_projects.push(project.clone());
261            } else {
262                non_runtime_names.insert(project.name.clone());
263            }
264        }
265    }
266
267    let config_str = format!(
268        "{}-{}",
269        options.platform.raw(),
270        options.build_config.raw()
271    );
272    let (variant_dir, result_prefix) = if let Some(out) = options.output_dir.as_deref() {
273        if out == "." {
274            (project_root.clone(), String::new())
275        } else {
276            let dir = join_path(&project_root, out);
277            create_dir_all(&dir);
278            (dir, format!("{}/", out))
279        }
280    } else {
281        let generator_dir = join_path(&project_root, &options.generator_root);
282        let dir = join_path(&generator_dir, &config_str);
283        create_dir_all(&dir);
284        (dir, format!("{}/{}/", options.generator_root, config_str))
285    };
286
287    GenerationContext {
288        project_root,
289        scan,
290        project_by_name,
291        patterns_by_project,
292        included_projects,
293        non_runtime_names,
294        variant_dir,
295        result_prefix,
296        warnings,
297    }
298}
299
300fn write_variant<R>(ctx: &GenerationContext, render_csproj: R) -> Result<GenerateResult>
301where
302    R: Fn(&ProjectInfo, &str, &str) -> String + Sync,
303{
304    let _span = tracing::info_span!("generate.write_variant", n = ctx.included_projects.len()).entered();
305    let included = &ctx.included_projects;
306    let patterns = &ctx.patterns_by_project;
307    let exclude_names = &ctx.non_runtime_names;
308    let asm_def_by_name = &ctx.scan.asm_def_by_name;
309    let project_by_name = &ctx.project_by_name;
310    let variant_dir = &ctx.variant_dir;
311
312    // Parallel csproj write — replaces Swift's DispatchQueue.concurrentPerform.
313    let results: Vec<Result<()>> = included
314        .par_iter()
315        .map(|project| -> Result<()> {
316            let empty: Vec<String> = Vec::new();
317            let pats = patterns.get(&project.name).unwrap_or(&empty);
318            let source_block = render_compile_patterns(pats);
319            let reference_block = render_project_references(
320                project,
321                asm_def_by_name,
322                project_by_name,
323                exclude_names,
324            );
325            let rendered = render_csproj(project, &source_block, &reference_block);
326            write_file_if_changed(&join_path(variant_dir, &project.csproj_path()), &rendered)?;
327            Ok(())
328        })
329        .collect();
330    for r in results {
331        r?;
332    }
333
334    let project_name = ctx
335        .project_root
336        .rsplit('/')
337        .find(|s| !s.is_empty())
338        .unwrap_or("Project")
339        .to_string();
340    let sln_name = format!("{}.sln", project_name);
341    write_file_if_changed(
342        &join_path(variant_dir, &sln_name),
343        &render_sln(&ctx.included_projects),
344    )?;
345
346    let mut variant_csprojs: Vec<String> = ctx
347        .included_projects
348        .iter()
349        .map(|p| format!("{}{}", ctx.result_prefix, p.csproj_path()))
350        .collect();
351    variant_csprojs.sort();
352
353    Ok(GenerateResult {
354        warnings: ctx.warnings.clone(),
355        variant_csprojs,
356        variant_sln_path: format!("{}{}", ctx.result_prefix, sln_name),
357    })
358}
359
360pub struct SolutionGenerator;
361
362impl SolutionGenerator {
363    pub fn new() -> Self {
364        SolutionGenerator
365    }
366
367    pub fn generate_from_lockfile(
368        &self,
369        options: &GenerateOptions,
370        lockfile: &Lockfile,
371    ) -> Result<GenerateResult> {
372        let _span = tracing::info_span!("generate.from_lockfile").entered();
373        let project_root = resolve_real_path(&options.project_root);
374
375        // Fast path: a previous successful generate with these exact options
376        // already left a fingerprint, the lockfile + scan-cache haven't changed,
377        // and every variant output file is still on disk. Reconstruct the
378        // GenerateResult and skip render+write entirely.
379        if let Some(cached) = {
380            let _s = tracing::info_span!("generate.fingerprint_check").entered();
381            crate::generate_cache::try_load_valid(&project_root, options)
382        } {
383            return Ok(cached);
384        }
385
386        let scan = ProjectScanner::scan(&project_root, &options.generator_root)?;
387
388        let mut projects: Vec<ProjectInfo> = Vec::new();
389        let mut all_names: HashSet<String> = HashSet::new();
390        for name in scan.asm_def_by_name.keys() {
391            if !scan.dirs_by_project.contains_key(name) {
392                continue;
393            }
394            projects.push(ProjectInfo {
395                name: name.clone(),
396                guid: deterministic_guid(name),
397            });
398            all_names.insert(name.clone());
399        }
400        for name in scan.dirs_by_project.keys() {
401            if !all_names.contains(name) {
402                projects.push(ProjectInfo {
403                    name: name.clone(),
404                    guid: deterministic_guid(name),
405                });
406            }
407        }
408        projects.sort_by(|a, b| a.name.cmp(&b.name));
409
410        let ctx = build_context(options, project_root.clone(), projects, scan);
411
412        let mut static_defines: Vec<String> = lockfile.defines.clone();
413        static_defines.extend(lockfile.defines_scripting.iter().cloned());
414        write_file_if_changed(
415            &join_path(&ctx.variant_dir, "Directory.Build.props"),
416            &render_directory_build_props(
417                &ctx.project_root,
418                Some(&lockfile.unity_path),
419                Some(&crate::paths::usg_cache_dir(&lockfile.unity_version)),
420                options.platform,
421                options.build_config,
422                &static_defines,
423            ),
424        )?;
425
426        let refs_block = collect_references_block(
427            lockfile,
428            options.platform,
429            options.build_config == BuildConfig::Editor,
430            &options.extra_refs,
431        );
432        let analyzer_block = render_analyzers(&lockfile.analyzers);
433        let lang_version = lockfile.lang_version.clone();
434        let asm_def_by_name = ctx.scan.asm_def_by_name.clone();
435
436        let result = write_variant(&ctx, |project, source_block, reference_block| {
437            let allow_unsafe = asm_def_by_name
438                .get(&project.name)
439                .map(|a| a.allow_unsafe_code)
440                .unwrap_or(false);
441            let mut out = render_csproj_header(
442                &project.name,
443                &project.guid,
444                &lang_version,
445                allow_unsafe,
446            );
447            out.push_str(&analyzer_block);
448            out.push_str(&refs_block);
449            out.push_str("  <ItemGroup>\n");
450            if !source_block.is_empty() {
451                out.push_str(source_block);
452                out.push('\n');
453            }
454            if !reference_block.is_empty() {
455                out.push_str(reference_block);
456                out.push('\n');
457            }
458            out.push_str("  </ItemGroup>\n");
459            out.push_str("  <Import Project=\"$(MSBuildToolsPath)\\Microsoft.CSharp.targets\" />\n");
460            out.push_str("</Project>\n");
461            out
462        })?;
463
464        crate::generate_cache::write_after_generate(&project_root, options, &result);
465        Ok(result)
466    }
467
468}
469
470impl Default for SolutionGenerator {
471    fn default() -> Self {
472        Self::new()
473    }
474}
475
476// ── rendering ─────────────────────────────────────────────────────────────
477
478fn render_compile_patterns(patterns: &[String]) -> String {
479    patterns
480        .iter()
481        .map(|p| format!("    <Compile Include=\"{}\" />", xml_escape(p)))
482        .collect::<Vec<_>>()
483        .join("\n")
484}
485
486fn render_project_references(
487    project: &ProjectInfo,
488    asm_def_by_name: &HashMap<String, AsmDefRecord>,
489    project_by_name: &HashMap<String, ProjectInfo>,
490    exclude_names: &HashSet<String>,
491) -> String {
492    let Some(asm_def) = asm_def_by_name.get(&project.name) else {
493        return String::new();
494    };
495    let mut seen: HashSet<String> = HashSet::new();
496    let mut blocks: Vec<String> = Vec::new();
497    for reference in &asm_def.references {
498        if exclude_names.contains(reference) {
499            continue;
500        }
501        let Some(ref_proj) = project_by_name.get(reference) else {
502            continue;
503        };
504        if !seen.insert(reference.clone()) {
505            continue;
506        }
507        blocks.push(format!(
508            "    <ProjectReference Include=\"{}\">\n      <Project>{}</Project>\n      <Name>{}</Name>\n    </ProjectReference>",
509            xml_escape(&ref_proj.csproj_path()),
510            ref_proj.guid,
511            xml_escape(&ref_proj.name),
512        ));
513    }
514    blocks.join("\n")
515}
516
517fn render_csproj_header(
518    project_name: &str,
519    project_guid: &str,
520    lang_version: &str,
521    allow_unsafe_blocks: bool,
522) -> String {
523    let unsafe_str = if allow_unsafe_blocks { "True" } else { "False" };
524    format!(
525        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
526<Project ToolsVersion=\"4.0\" DefaultTargets=\"Build\" xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\">\n\
527  <PropertyGroup>\n\
528    <LangVersion>{lang_version}</LangVersion>\n\
529    <_TargetFrameworkDirectories>non_empty_path_generated_by_unity.rider.package</_TargetFrameworkDirectories>\n\
530    <_FullFrameworkReferenceAssemblyPaths>non_empty_path_generated_by_unity.rider.package</_FullFrameworkReferenceAssemblyPaths>\n\
531    <DisableHandlePackageFileConflicts>true</DisableHandlePackageFileConflicts>\n\
532  </PropertyGroup>\n\
533  <PropertyGroup>\n\
534    <Configuration Condition=\" '$(Configuration)' == '' \">Debug</Configuration>\n\
535    <Platform Condition=\" '$(Platform)' == '' \">AnyCPU</Platform>\n\
536    <ProductVersion>10.0.20506</ProductVersion>\n\
537    <SchemaVersion>2.0</SchemaVersion>\n\
538    <RootNamespace></RootNamespace>\n\
539    <ProjectGuid>{project_guid}</ProjectGuid>\n\
540    <ProjectTypeGuids>{{E097FAD1-6243-4DAD-9C02-E9B9EFC3FFC1}};{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}</ProjectTypeGuids>\n\
541    <OutputType>Library</OutputType>\n\
542    <AppDesignerFolder>Properties</AppDesignerFolder>\n\
543    <AssemblyName>{project_name}</AssemblyName>\n\
544    <TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion>\n\
545    <FileAlignment>512</FileAlignment>\n\
546    <BaseDirectory>.</BaseDirectory>\n\
547  </PropertyGroup>\n\
548  <PropertyGroup Condition=\" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' \">\n\
549    <DebugSymbols>true</DebugSymbols>\n\
550    <DebugType>full</DebugType>\n\
551    <Optimize>false</Optimize>\n\
552    <OutputPath>Temp\\Bin\\Debug\\{project_name}\\</OutputPath>\n\
553    <DefineConstants>$(DefineConstants)</DefineConstants>\n\
554    <ErrorReport>prompt</ErrorReport>\n\
555    <WarningLevel>4</WarningLevel>\n\
556    <NoWarn>0169,0649</NoWarn>\n\
557    <AllowUnsafeBlocks>{unsafe_str}</AllowUnsafeBlocks>\n\
558    <TreatWarningsAsErrors>False</TreatWarningsAsErrors>\n\
559  </PropertyGroup>\n\
560  <PropertyGroup>\n\
561    <NoConfig>true</NoConfig>\n\
562    <NoStdLib>true</NoStdLib>\n\
563    <AddAdditionalExplicitAssemblyReferences>false</AddAdditionalExplicitAssemblyReferences>\n\
564    <ImplicitlyExpandNETStandardFacades>false</ImplicitlyExpandNETStandardFacades>\n\
565    <ImplicitlyExpandDesignTimeFacades>false</ImplicitlyExpandDesignTimeFacades>\n\
566  </PropertyGroup>\n",
567    )
568}
569
570fn render_analyzers(analyzers: &[String]) -> String {
571    if analyzers.is_empty() {
572        return String::new();
573    }
574    let mut s = String::from("  <ItemGroup>\n");
575    for path in analyzers {
576        s.push_str(&format!("    <Analyzer Include=\"{}\" />\n", xml_escape(path)));
577    }
578    s.push_str("  </ItemGroup>\n");
579    s
580}
581
582fn collect_references_block(
583    lockfile: &Lockfile,
584    platform: BuildPlatform,
585    is_editor: bool,
586    extra_refs: &[DllRef],
587) -> String {
588    let mut refs: Vec<&DllRef> = Vec::new();
589    let mut seen: HashSet<String> = HashSet::new();
590    let mut cats: Vec<RefCategory> = vec![RefCategory::Engine];
591    if is_editor {
592        cats.push(RefCategory::Editor);
593    }
594    cats.push(RefCategory::PlaybackStandalone);
595    match platform {
596        BuildPlatform::Ios => cats.push(RefCategory::PlaybackIos),
597        BuildPlatform::Android => cats.push(RefCategory::PlaybackAndroid),
598        BuildPlatform::Osx => {}
599    }
600    cats.push(RefCategory::Project);
601    cats.push(RefCategory::Netstandard);
602    for cat in cats {
603        for r in lockfile.refs_for(cat) {
604            if seen.insert(r.name.clone()) {
605                refs.push(r);
606            }
607        }
608    }
609    for r in extra_refs {
610        if seen.insert(r.name.clone()) {
611            refs.push(r);
612        }
613    }
614
615    if refs.is_empty() {
616        return String::new();
617    }
618    let mut s = String::from("  <ItemGroup>\n");
619    for r in &refs {
620        s.push_str(&format!("    <Reference Include=\"{}\">\n", xml_escape(&r.name)));
621        s.push_str(&format!("      <HintPath>{}</HintPath>\n", xml_escape(&r.path)));
622        s.push_str("    </Reference>\n");
623    }
624    s.push_str("  </ItemGroup>\n");
625    s
626}
627
628pub fn render_directory_build_props(
629    project_root: &str,
630    unity_path: Option<&str>,
631    usg_cache: Option<&str>,
632    platform: BuildPlatform,
633    build_config: BuildConfig,
634    static_defines: &[String],
635) -> String {
636    let mut dynamic: Vec<&str> = platform.platform_defines().to_vec();
637    if build_config == BuildConfig::Editor {
638        dynamic.extend_from_slice(EDITOR_DEFINES);
639    }
640    if matches!(build_config, BuildConfig::Editor | BuildConfig::Dev) {
641        dynamic.extend_from_slice(DEBUG_DEFINES);
642    }
643    let mut all: Vec<String> = static_defines.to_vec();
644    all.extend(dynamic.iter().map(|s| s.to_string()));
645
646    let mut props = format!(
647        "<Project>\n<PropertyGroup>\n<ProjectRoot>{}</ProjectRoot>\n",
648        project_root
649    );
650    if let Some(up) = unity_path {
651        props.push_str(&format!("<UnityPath>{}</UnityPath>\n", up));
652    }
653    if let Some(uc) = usg_cache {
654        props.push_str(&format!("<UsgCache>{}</UsgCache>\n", uc));
655    }
656    props.push_str(&format!(
657        "<DefineConstants>$(DefineConstants);{}</DefineConstants>\n</PropertyGroup>\n</Project>\n",
658        all.join(";")
659    ));
660    props
661}
662
663const CSHARP_PROJECT_TYPE_GUID: &str = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}";
664
665fn render_sln(projects: &[ProjectInfo]) -> String {
666    let mut lines: Vec<String> = vec![
667        "Microsoft Visual Studio Solution File, Format Version 11.00".into(),
668        "# Visual Studio 2010".into(),
669    ];
670    for p in projects {
671        lines.push(format!(
672            "Project(\"{}\") = \"{}\", \"{}\", \"{}\"",
673            CSHARP_PROJECT_TYPE_GUID,
674            p.name,
675            p.csproj_path(),
676            p.guid
677        ));
678        lines.push("EndProject".into());
679    }
680    lines.push("Global".into());
681    lines.push("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution".into());
682    lines.push("\t\tDebug|Any CPU = Debug|Any CPU".into());
683    lines.push("\tEndGlobalSection".into());
684    lines.push("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution".into());
685    for p in projects {
686        lines.push(format!("\t\t{}.Debug|Any CPU.ActiveCfg = Debug|Any CPU", p.guid));
687        lines.push(format!("\t\t{}.Debug|Any CPU.Build.0 = Debug|Any CPU", p.guid));
688    }
689    lines.push("\tEndGlobalSection".into());
690    lines.push("EndGlobal".into());
691    lines.push(String::new());
692    lines.join("\n")
693}