Emitting the Root Model
public static string EmitGitLabCiFile(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("public partial class GitLabCiFile");
sb.AppendLine("{");
foreach (var up in schema.RootProperties)
{
var prop = up.Property;
var type = MapRootPropertyType(prop);
EmitVersionAttributes(sb, up);
if (prop.IsDeprecated)
sb.AppendLine(" [global::System.Obsolete(\"Deprecated.\")]");
if (prop.Description is not null)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// {EscapeXml(prop.Description)}");
sb.AppendLine(" /// </summary>");
}
sb.AppendLine($" public {type} {prop.CSharpName} {{ get; set; }}");
sb.AppendLine();
}
// Jobs dictionary — arbitrary job names at root level
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Named jobs. In YAML these appear as top-level keys " +
"(not under a 'jobs' key).");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public global::System.Collections.Generic" +
".Dictionary<string, GitLabCiJob>? Jobs { get; set; }");
sb.AppendLine();
sb.AppendLine(" public global::System.Collections.Generic" +
".Dictionary<string, object?>? Extensions { get; set; }");
sb.AppendLine("}");
return sb.ToString();
}public static string EmitGitLabCiFile(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("public partial class GitLabCiFile");
sb.AppendLine("{");
foreach (var up in schema.RootProperties)
{
var prop = up.Property;
var type = MapRootPropertyType(prop);
EmitVersionAttributes(sb, up);
if (prop.IsDeprecated)
sb.AppendLine(" [global::System.Obsolete(\"Deprecated.\")]");
if (prop.Description is not null)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// {EscapeXml(prop.Description)}");
sb.AppendLine(" /// </summary>");
}
sb.AppendLine($" public {type} {prop.CSharpName} {{ get; set; }}");
sb.AppendLine();
}
// Jobs dictionary — arbitrary job names at root level
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Named jobs. In YAML these appear as top-level keys " +
"(not under a 'jobs' key).");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public global::System.Collections.Generic" +
".Dictionary<string, GitLabCiJob>? Jobs { get; set; }");
sb.AppendLine();
sb.AppendLine(" public global::System.Collections.Generic" +
".Dictionary<string, object?>? Extensions { get; set; }");
sb.AppendLine("}");
return sb.ToString();
}Key design decisions:
partial class— allows consumers to extend the generated class with additional methodsExcludeFromCodeCoverage— generated code shouldn't affect coverage metrics#nullable enable— all properties are nullable (YAML properties are optional)- Global type qualifiers —
global::System.Collections.Generic.List<>avoids namespace conflicts Jobsas explicit property — not generated from the schema'sadditionalProperties, but hand-added to the emitterExtensionsdictionary — forward-compatibility for unknown root-level properties
Generated Root Model Output
Here's the actual generated GitLabCiFile.g.cs:
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiFile
{
/// <summary>
/// Specification for pipeline configuration. Must be declared at the top of
/// a configuration file, in a header section separated from the rest of the
/// configuration with `---`.
/// </summary>
public GitLabCiSpec? Spec { get; set; }
/// <summary>
/// Defining `image` globally is deprecated. Use `default` instead.
/// </summary>
public string? Image { get; set; }
/// <summary>
/// Defining `services` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<object>? Services { get; set; }
/// <summary>
/// Defining `before_script` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }
/// <summary>
/// Defining `after_script` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<string>? AfterScript { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; set; }
/// <summary>
/// Defining `cache` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<object>? Cache { get; set; }
public GitLabCiDefault? Default { get; set; }
/// <summary>
/// Groups jobs into stages. All jobs in one stage must complete before next
/// stage is executed. Defaults to ['build', 'test', 'deploy'].
/// </summary>
public global::System.Collections.Generic.List<object>? Stages { get; set; }
/// <summary>
/// Can be IncludeItem or IncludeItem[]. Each IncludeItem will be a string,
/// or an object with properties for the method if including external YAML file.
/// </summary>
public object? Include { get; set; }
/// <summary>
/// A special job used to upload static sites to GitLab pages.
/// </summary>
public GitLabCiJob? Pages { get; set; }
public GitLabCiWorkflow? Workflow { get; set; }
/// <summary>
/// Named jobs. In YAML these appear as top-level keys (not under a 'jobs' key).
/// </summary>
public global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
Jobs { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Extensions { get; set; }
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiFile
{
/// <summary>
/// Specification for pipeline configuration. Must be declared at the top of
/// a configuration file, in a header section separated from the rest of the
/// configuration with `---`.
/// </summary>
public GitLabCiSpec? Spec { get; set; }
/// <summary>
/// Defining `image` globally is deprecated. Use `default` instead.
/// </summary>
public string? Image { get; set; }
/// <summary>
/// Defining `services` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<object>? Services { get; set; }
/// <summary>
/// Defining `before_script` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }
/// <summary>
/// Defining `after_script` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<string>? AfterScript { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; set; }
/// <summary>
/// Defining `cache` globally is deprecated. Use `default` instead.
/// </summary>
public global::System.Collections.Generic.List<object>? Cache { get; set; }
public GitLabCiDefault? Default { get; set; }
/// <summary>
/// Groups jobs into stages. All jobs in one stage must complete before next
/// stage is executed. Defaults to ['build', 'test', 'deploy'].
/// </summary>
public global::System.Collections.Generic.List<object>? Stages { get; set; }
/// <summary>
/// Can be IncludeItem or IncludeItem[]. Each IncludeItem will be a string,
/// or an object with properties for the method if including external YAML file.
/// </summary>
public object? Include { get; set; }
/// <summary>
/// A special job used to upload static sites to GitLab pages.
/// </summary>
public GitLabCiJob? Pages { get; set; }
public GitLabCiWorkflow? Workflow { get; set; }
/// <summary>
/// Named jobs. In YAML these appear as top-level keys (not under a 'jobs' key).
/// </summary>
public global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
Jobs { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Extensions { get; set; }
}Emitting Definition Classes
public static IEnumerable<(string FileName, string Source)> EmitDefinitions(
string ns, UnifiedSchema schema)
{
foreach (var kvp in schema.Definitions)
{
var defName = kvp.Key;
var def = kvp.Value;
// Skip type aliases — they don't need classes
if (HelperDefinitions.Contains(defName))
continue;
// Skip definitions with no properties
if (def.Properties.Count == 0)
continue;
var className = NamingHelper.DefinitionToClassName(defName);
var source = EmitClass(ns, className, def.Description, def.Properties,
def.SinceVersion, def.UntilVersion);
yield return ($"{className}.g.cs", source);
// Emit inline object classes found in properties
foreach (var inline in CollectInlineClasses(ns, def.Properties))
yield return inline;
}
// Also collect inline classes from root properties
foreach (var inline in CollectInlineClasses(ns, schema.RootProperties))
yield return inline;
}public static IEnumerable<(string FileName, string Source)> EmitDefinitions(
string ns, UnifiedSchema schema)
{
foreach (var kvp in schema.Definitions)
{
var defName = kvp.Key;
var def = kvp.Value;
// Skip type aliases — they don't need classes
if (HelperDefinitions.Contains(defName))
continue;
// Skip definitions with no properties
if (def.Properties.Count == 0)
continue;
var className = NamingHelper.DefinitionToClassName(defName);
var source = EmitClass(ns, className, def.Description, def.Properties,
def.SinceVersion, def.UntilVersion);
yield return ($"{className}.g.cs", source);
// Emit inline object classes found in properties
foreach (var inline in CollectInlineClasses(ns, def.Properties))
yield return inline;
}
// Also collect inline classes from root properties
foreach (var inline in CollectInlineClasses(ns, schema.RootProperties))
yield return inline;
}The HelperDefinitions set contains 40+ definition names that are type aliases:
private static readonly HashSet<string> HelperDefinitions = new()
{
"string_or_list", "stringOrList",
"string_file_list", "include_item", "!reference",
"image", "services", "identity",
"script", "steps", "optional_script",
"before_script", "after_script",
"rules", "includeRules",
"workflowName", "if", "changes", "exists",
"timeout", "start_in", "rulesNeeds",
"allow_failure", "parallel", "parallel_matrix",
"when", "cache", "filter_refs", "filter",
"retry", "retry_max", "retry_errors",
"interruptible",
"tags", "step", "stepName", "stepFuncReference",
"configInputs", "jobInputs", "inputs",
"globalVariables", "jobVariables", "rulesVariables",
"id_tokens", "secrets",
"stepNamedStrings", "stepNamedValues",
};private static readonly HashSet<string> HelperDefinitions = new()
{
"string_or_list", "stringOrList",
"string_file_list", "include_item", "!reference",
"image", "services", "identity",
"script", "steps", "optional_script",
"before_script", "after_script",
"rules", "includeRules",
"workflowName", "if", "changes", "exists",
"timeout", "start_in", "rulesNeeds",
"allow_failure", "parallel", "parallel_matrix",
"when", "cache", "filter_refs", "filter",
"retry", "retry_max", "retry_errors",
"interruptible",
"tags", "step", "stepName", "stepFuncReference",
"configInputs", "jobInputs", "inputs",
"globalVariables", "jobVariables", "rulesVariables",
"id_tokens", "secrets",
"stepNamedStrings", "stepNamedValues",
};Collecting Inline Classes
Inline classes arise when a property is defined as type: object with inline properties rather than using a $ref. The emitter recursively discovers these:
private static IEnumerable<(string FileName, string Source)> CollectInlineClasses(
string ns, List<UnifiedProperty> properties)
{
foreach (var up in properties)
{
var prop = up.Property;
// Inline object property → generate class
if (prop.Type == PropertyType.InlineObject &&
prop.InlineClassName is not null &&
prop.InlineObjectProperties is not null)
{
var unifiedProps = prop.InlineObjectProperties
.ConvertAll(p => new UnifiedProperty { Property = p });
var source = EmitClass(ns, prop.InlineClassName, null,
unifiedProps, null, null);
yield return ($"{prop.InlineClassName}.g.cs", source);
// Recurse into nested inline objects
foreach (var nested in CollectInlineClasses(ns, unifiedProps))
yield return nested;
}
// oneOf[string, object] with inline object
if (prop.Type == PropertyType.StringOrObject &&
prop.OneOfObjectClassName is not null &&
prop.OneOfObjectProperties is not null)
{
var unifiedProps = prop.OneOfObjectProperties
.ConvertAll(p => new UnifiedProperty { Property = p });
var source = EmitClass(ns, prop.OneOfObjectClassName, null,
unifiedProps, null, null);
yield return ($"{prop.OneOfObjectClassName}.g.cs", source);
foreach (var nested in CollectInlineClasses(ns, unifiedProps))
yield return nested;
}
// Array of inline objects
if (prop.Type == PropertyType.Array &&
prop.Items?.Type == PropertyType.InlineObject &&
prop.Items.InlineClassName is not null &&
prop.Items.InlineObjectProperties is not null)
{
// ... same pattern for array items
}
// Array of oneOf items with inline object
if (prop.Type == PropertyType.Array &&
prop.Items?.Type == PropertyType.StringOrObject &&
prop.Items.OneOfObjectClassName is not null &&
prop.Items.OneOfObjectProperties is not null)
{
// ... same pattern for oneOf array items
}
}
}private static IEnumerable<(string FileName, string Source)> CollectInlineClasses(
string ns, List<UnifiedProperty> properties)
{
foreach (var up in properties)
{
var prop = up.Property;
// Inline object property → generate class
if (prop.Type == PropertyType.InlineObject &&
prop.InlineClassName is not null &&
prop.InlineObjectProperties is not null)
{
var unifiedProps = prop.InlineObjectProperties
.ConvertAll(p => new UnifiedProperty { Property = p });
var source = EmitClass(ns, prop.InlineClassName, null,
unifiedProps, null, null);
yield return ($"{prop.InlineClassName}.g.cs", source);
// Recurse into nested inline objects
foreach (var nested in CollectInlineClasses(ns, unifiedProps))
yield return nested;
}
// oneOf[string, object] with inline object
if (prop.Type == PropertyType.StringOrObject &&
prop.OneOfObjectClassName is not null &&
prop.OneOfObjectProperties is not null)
{
var unifiedProps = prop.OneOfObjectProperties
.ConvertAll(p => new UnifiedProperty { Property = p });
var source = EmitClass(ns, prop.OneOfObjectClassName, null,
unifiedProps, null, null);
yield return ($"{prop.OneOfObjectClassName}.g.cs", source);
foreach (var nested in CollectInlineClasses(ns, unifiedProps))
yield return nested;
}
// Array of inline objects
if (prop.Type == PropertyType.Array &&
prop.Items?.Type == PropertyType.InlineObject &&
prop.Items.InlineClassName is not null &&
prop.Items.InlineObjectProperties is not null)
{
// ... same pattern for array items
}
// Array of oneOf items with inline object
if (prop.Type == PropertyType.Array &&
prop.Items?.Type == PropertyType.StringOrObject &&
prop.Items.OneOfObjectClassName is not null &&
prop.Items.OneOfObjectProperties is not null)
{
// ... same pattern for oneOf array items
}
}
}This recursion produces deeply nested class names like GitLabCiJobTemplateEnvironmentConfigKubernetesManagedResources — verbose, but unambiguous and fully IntelliSense-friendly.
Generated Job Model
Here's the actual generated GitLabCiJob.g.cs (showing the 30+ properties):
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiJob
{
public string? Image { get; set; }
public global::System.Collections.Generic.List<object>? Services { get; set; }
public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }
public global::System.Collections.Generic.List<string>? AfterScript { get; set; }
public GitLabCiHooks? Hooks { get; set; }
public global::System.Collections.Generic.List<object>? Rules { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; set; }
public global::System.Collections.Generic.List<object>? Cache { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
IdTokens { get; set; }
public string? Identity { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Secrets { get; set; }
/// <summary>
/// Shell scripts executed by the Runner. The only required property of jobs.
/// </summary>
public global::System.Collections.Generic.List<string>? Script { get; set; }
/// <summary>
/// Specifies a list of steps to execute in the job. The `run` keyword is an
/// alternative to `script` and allows for more advanced job configuration.
/// </summary>
public global::System.Collections.Generic.List<object>? Run { get; set; }
/// <summary>Define what stage the job will run in.</summary>
public global::System.Collections.Generic.List<string>? Stage { get; set; }
/// <summary>Job will run *only* when these filtering options match.</summary>
public global::System.Collections.Generic.Dictionary<string, object?>?
Only { get; set; }
/// <summary>
/// The name of one or more jobs to inherit configuration from.
/// </summary>
public global::System.Collections.Generic.List<string>? Extends { get; set; }
/// <summary>
/// The list of jobs in previous stages whose sole completion is needed
/// to start the current job.
/// </summary>
public global::System.Collections.Generic.List<GitLabCiJobTemplateNeedsConfig>?
Needs { get; set; }
/// <summary>
/// Job will run *except* for when these filtering options match.
/// </summary>
public global::System.Collections.Generic.Dictionary<string, object?>?
Except { get; set; }
public global::System.Collections.Generic.List<object>? Tags { get; set; }
public bool? AllowFailure { get; set; }
public string? Timeout { get; set; }
public string? When { get; set; }
public string? StartIn { get; set; }
/// <summary>
/// Describes the Custom confirmation message for a manual job.
/// </summary>
public string? ManualConfirmation { get; set; }
/// <summary>
/// Specify a list of job names from earlier stages from which artifacts
/// should be loaded.
/// </summary>
public global::System.Collections.Generic.List<string>?
Dependencies { get; set; }
public GitLabCiArtifacts? Artifacts { get; set; }
/// <summary>
/// Used to associate environment metadata with a deploy.
/// </summary>
public GitLabCiJobTemplateEnvironmentConfig? Environment { get; set; }
/// <summary>Indicates that the job creates a Release.</summary>
public GitLabCiJobTemplateRelease? Release { get; set; }
/// <summary>
/// Must be a regular expression, optionally but recommended to be quoted.
/// </summary>
public string? Coverage { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Retry { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Parallel { get; set; }
public bool? Interruptible { get; set; }
/// <summary>
/// Limit job concurrency. Can be used to ensure that the Runner will
/// not run certain jobs simultaneously.
/// </summary>
public string? ResourceGroup { get; set; }
/// <summary>
/// Trigger allows you to define downstream pipeline trigger.
/// </summary>
public GitLabCiJobTemplateTriggerConfig? Trigger { get; set; }
/// <summary>
/// Controls inheritance of globally-defined defaults and variables.
/// </summary>
public GitLabCiJobTemplateInherit? Inherit { get; set; }
/// <summary>
/// Deprecated. Use `pages.publish` instead.
/// </summary>
public string? Publish { get; set; }
public string? Pages { get; set; }
[SinceVersion("18.6.0")]
public global::System.Collections.Generic.Dictionary<string, object?>?
Inputs { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Extensions { get; set; }
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiJob
{
public string? Image { get; set; }
public global::System.Collections.Generic.List<object>? Services { get; set; }
public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }
public global::System.Collections.Generic.List<string>? AfterScript { get; set; }
public GitLabCiHooks? Hooks { get; set; }
public global::System.Collections.Generic.List<object>? Rules { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; set; }
public global::System.Collections.Generic.List<object>? Cache { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
IdTokens { get; set; }
public string? Identity { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Secrets { get; set; }
/// <summary>
/// Shell scripts executed by the Runner. The only required property of jobs.
/// </summary>
public global::System.Collections.Generic.List<string>? Script { get; set; }
/// <summary>
/// Specifies a list of steps to execute in the job. The `run` keyword is an
/// alternative to `script` and allows for more advanced job configuration.
/// </summary>
public global::System.Collections.Generic.List<object>? Run { get; set; }
/// <summary>Define what stage the job will run in.</summary>
public global::System.Collections.Generic.List<string>? Stage { get; set; }
/// <summary>Job will run *only* when these filtering options match.</summary>
public global::System.Collections.Generic.Dictionary<string, object?>?
Only { get; set; }
/// <summary>
/// The name of one or more jobs to inherit configuration from.
/// </summary>
public global::System.Collections.Generic.List<string>? Extends { get; set; }
/// <summary>
/// The list of jobs in previous stages whose sole completion is needed
/// to start the current job.
/// </summary>
public global::System.Collections.Generic.List<GitLabCiJobTemplateNeedsConfig>?
Needs { get; set; }
/// <summary>
/// Job will run *except* for when these filtering options match.
/// </summary>
public global::System.Collections.Generic.Dictionary<string, object?>?
Except { get; set; }
public global::System.Collections.Generic.List<object>? Tags { get; set; }
public bool? AllowFailure { get; set; }
public string? Timeout { get; set; }
public string? When { get; set; }
public string? StartIn { get; set; }
/// <summary>
/// Describes the Custom confirmation message for a manual job.
/// </summary>
public string? ManualConfirmation { get; set; }
/// <summary>
/// Specify a list of job names from earlier stages from which artifacts
/// should be loaded.
/// </summary>
public global::System.Collections.Generic.List<string>?
Dependencies { get; set; }
public GitLabCiArtifacts? Artifacts { get; set; }
/// <summary>
/// Used to associate environment metadata with a deploy.
/// </summary>
public GitLabCiJobTemplateEnvironmentConfig? Environment { get; set; }
/// <summary>Indicates that the job creates a Release.</summary>
public GitLabCiJobTemplateRelease? Release { get; set; }
/// <summary>
/// Must be a regular expression, optionally but recommended to be quoted.
/// </summary>
public string? Coverage { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Retry { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Parallel { get; set; }
public bool? Interruptible { get; set; }
/// <summary>
/// Limit job concurrency. Can be used to ensure that the Runner will
/// not run certain jobs simultaneously.
/// </summary>
public string? ResourceGroup { get; set; }
/// <summary>
/// Trigger allows you to define downstream pipeline trigger.
/// </summary>
public GitLabCiJobTemplateTriggerConfig? Trigger { get; set; }
/// <summary>
/// Controls inheritance of globally-defined defaults and variables.
/// </summary>
public GitLabCiJobTemplateInherit? Inherit { get; set; }
/// <summary>
/// Deprecated. Use `pages.publish` instead.
/// </summary>
public string? Publish { get; set; }
public string? Pages { get; set; }
[SinceVersion("18.6.0")]
public global::System.Collections.Generic.Dictionary<string, object?>?
Inputs { get; set; }
public global::System.Collections.Generic.Dictionary<string, object?>?
Extensions { get; set; }
}Notice the [SinceVersion("18.6.0")] on Inputs — this property was introduced in GitLab 18.6 and the version merger tracked it automatically.
Code Emission: BuilderHelper and BuilderEmitter
Every model class gets a corresponding builder class. The BuilderHelper bridges the gap between the schema-derived property models and the BuilderEmitter (from FrenchExDev.Net.Builder.SourceGenerator.Lib).
Creating Builder Models
internal static class BuilderHelper
{
public static BuilderEmitModel CreateBuilderModel(
string ns, string className, List<UnifiedProperty> properties)
{
var builderProps = new List<BuilderPropertyModel>();
foreach (var up in properties)
{
var prop = up.Property;
var csharpType = NamingHelper.MapCSharpType(prop);
var (isCollection, itemType) = DetectCollection(csharpType);
var (isDict, dictKey, dictValue) = DetectDictionary(csharpType);
var dictValueBuilder = isDict
? ResolveValueBuilderClassName(dictValue) : null;
builderProps.Add(new BuilderPropertyModel(
prop.CSharpName,
csharpType,
csharpType,
isCollection,
itemType,
withMethodAttributes: BuildVersionAttributes(up),
isDictionary: isDict,
dictKeyTypeFull: dictKey,
dictValueTypeFull: dictValue,
dictValueBuilderClassName: dictValueBuilder,
dictSingularName: dictValueBuilder is not null
? Singularize(prop.CSharpName) : null));
}
// Every model has an Extensions property
builderProps.Add(CreateExtensionsProperty());
return new BuilderEmitModel(
ns, className, className + "Builder", builderProps);
}
}internal static class BuilderHelper
{
public static BuilderEmitModel CreateBuilderModel(
string ns, string className, List<UnifiedProperty> properties)
{
var builderProps = new List<BuilderPropertyModel>();
foreach (var up in properties)
{
var prop = up.Property;
var csharpType = NamingHelper.MapCSharpType(prop);
var (isCollection, itemType) = DetectCollection(csharpType);
var (isDict, dictKey, dictValue) = DetectDictionary(csharpType);
var dictValueBuilder = isDict
? ResolveValueBuilderClassName(dictValue) : null;
builderProps.Add(new BuilderPropertyModel(
prop.CSharpName,
csharpType,
csharpType,
isCollection,
itemType,
withMethodAttributes: BuildVersionAttributes(up),
isDictionary: isDict,
dictKeyTypeFull: dictKey,
dictValueTypeFull: dictValue,
dictValueBuilderClassName: dictValueBuilder,
dictSingularName: dictValueBuilder is not null
? Singularize(prop.CSharpName) : null));
}
// Every model has an Extensions property
builderProps.Add(CreateExtensionsProperty());
return new BuilderEmitModel(
ns, className, className + "Builder", builderProps);
}
}Collection and Dictionary Detection
The helper parses C# type strings to detect collections and dictionaries:
private static (bool IsCollection, string? ItemType) DetectCollection(
string csharpType)
{
const string listPrefix = "global::System.Collections.Generic.List<";
if (csharpType.StartsWith(listPrefix))
{
var inner = csharpType.Substring(listPrefix.Length);
var closingBracket = inner.LastIndexOf('>');
if (closingBracket > 0)
{
var itemType = inner.Substring(0, closingBracket);
return (true, itemType);
}
}
return (false, null);
}
private static (bool IsDictionary, string? KeyType, string? ValueType)
DetectDictionary(string csharpType)
{
const string dictPrefix = "global::System.Collections.Generic.Dictionary<";
if (!csharpType.StartsWith(dictPrefix))
return (false, null, null);
var inner = csharpType.Substring(dictPrefix.Length);
var closingBracket = inner.LastIndexOf('>');
if (closingBracket <= 0)
return (false, null, null);
var keyValue = inner.Substring(0, closingBracket);
var commaIdx = keyValue.IndexOf(", ");
if (commaIdx <= 0)
return (false, null, null);
var keyType = keyValue.Substring(0, commaIdx);
var valueType = keyValue.Substring(commaIdx + 2);
return (true, keyType, valueType);
}private static (bool IsCollection, string? ItemType) DetectCollection(
string csharpType)
{
const string listPrefix = "global::System.Collections.Generic.List<";
if (csharpType.StartsWith(listPrefix))
{
var inner = csharpType.Substring(listPrefix.Length);
var closingBracket = inner.LastIndexOf('>');
if (closingBracket > 0)
{
var itemType = inner.Substring(0, closingBracket);
return (true, itemType);
}
}
return (false, null);
}
private static (bool IsDictionary, string? KeyType, string? ValueType)
DetectDictionary(string csharpType)
{
const string dictPrefix = "global::System.Collections.Generic.Dictionary<";
if (!csharpType.StartsWith(dictPrefix))
return (false, null, null);
var inner = csharpType.Substring(dictPrefix.Length);
var closingBracket = inner.LastIndexOf('>');
if (closingBracket <= 0)
return (false, null, null);
var keyValue = inner.Substring(0, closingBracket);
var commaIdx = keyValue.IndexOf(", ");
if (commaIdx <= 0)
return (false, null, null);
var keyType = keyValue.Substring(0, commaIdx);
var valueType = keyValue.Substring(commaIdx + 2);
return (true, keyType, valueType);
}Nested Builder Resolution
When a dictionary's value type is a known model class (not object or string), the builder generates a typed builder callback:
private static string? ResolveValueBuilderClassName(string? valueType)
{
if (valueType is null) return null;
var clean = valueType.TrimEnd('?').Trim();
if (clean == "object" || clean == "string" ||
clean.StartsWith("global::System"))
return null;
return clean + "Builder";
}private static string? ResolveValueBuilderClassName(string? valueType)
{
if (valueType is null) return null;
var clean = valueType.TrimEnd('?').Trim();
if (clean == "object" || clean == "string" ||
clean.StartsWith("global::System"))
return null;
return clean + "Builder";
}For example, Dictionary<string, GitLabCiJob> gets GitLabCiJobBuilder as its value builder, enabling:
builder.WithJob("build", job => job
.WithImage("node:20")
.WithScript(new List<string> { "npm ci", "npm run build" }))builder.WithJob("build", job => job
.WithImage("node:20")
.WithScript(new List<string> { "npm ci", "npm run build" }))Root Builder Special Handling
The root GitLabCiFileBuilder gets special treatment because the Jobs dictionary isn't in the schema — it's the hand-added property from EmitGitLabCiFile:
public static BuilderEmitModel CreateRootBuilderModel(
string ns, string className, List<UnifiedProperty> rootProperties)
{
var builderProps = new List<BuilderPropertyModel>();
// Standard root properties from schema
foreach (var up in rootProperties)
{
// ... same as CreateBuilderModel
}
// Jobs dictionary — hand-added
const string jobsDictType =
"global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?";
builderProps.Add(new BuilderPropertyModel(
"Jobs", jobsDictType, jobsDictType,
isDictionary: true,
dictKeyTypeFull: "string",
dictValueTypeFull: "GitLabCiJob",
dictValueBuilderClassName: "GitLabCiJobBuilder",
dictSingularName: "Job"));
builderProps.Add(CreateExtensionsProperty());
return new BuilderEmitModel(
ns, className, className + "Builder", builderProps);
}public static BuilderEmitModel CreateRootBuilderModel(
string ns, string className, List<UnifiedProperty> rootProperties)
{
var builderProps = new List<BuilderPropertyModel>();
// Standard root properties from schema
foreach (var up in rootProperties)
{
// ... same as CreateBuilderModel
}
// Jobs dictionary — hand-added
const string jobsDictType =
"global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?";
builderProps.Add(new BuilderPropertyModel(
"Jobs", jobsDictType, jobsDictType,
isDictionary: true,
dictKeyTypeFull: "string",
dictValueTypeFull: "GitLabCiJob",
dictValueBuilderClassName: "GitLabCiJobBuilder",
dictSingularName: "Job"));
builderProps.Add(CreateExtensionsProperty());
return new BuilderEmitModel(
ns, className, className + "Builder", builderProps);
}The dictSingularName: "Job" parameter generates the WithJob(string key, Action<GitLabCiJobBuilder> configure) convenience method.
Generated Builder Output
The generated GitLabCiFileBuilder.g.cs includes:
public partial class GitLabCiFileBuilder
: global::FrenchExDev.Net.Builder.AbstractBuilder<GitLabCiFile>
{
// ── Input properties ─────────────────────────────────────────
protected GitLabCiSpec? Spec { get; private set; }
public GitLabCiFileBuilder WithSpec(GitLabCiSpec? value)
{ Spec = value; return this; }
protected string? Image { get; private set; }
public GitLabCiFileBuilder WithImage(string? value)
{ Image = value; return this; }
protected global::System.Collections.Generic.List<object>? Stages
{ get; private set; }
public GitLabCiFileBuilder WithStages(
global::System.Collections.Generic.List<object>? value)
{ Stages = value; return this; }
// Dictionary with inline builder
protected global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; private set; }
public GitLabCiFileBuilder WithVariables(
global::System.Collections.Generic.Dictionary<string, object?>? value)
{ Variables = value; return this; }
public GitLabCiFileBuilder WithVariables(
global::System.Action<global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, object?>> configure)
{
var __b = new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, object?>();
configure(__b);
Variables = __b.Build();
return this;
}
// Jobs dictionary with typed builder
protected global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
Jobs { get; private set; }
public GitLabCiFileBuilder WithJobs(
global::System.Collections.Generic.Dictionary<string, GitLabCiJob>? value)
{ Jobs = value; return this; }
private global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>? __JobsBuilder;
public GitLabCiFileBuilder WithJobs(
global::System.Action<global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>> configure)
{
__JobsBuilder = new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
configure(__JobsBuilder);
return this;
}
public GitLabCiFileBuilder WithJob(string key,
global::System.Action<GitLabCiJobBuilder> configure)
{
__JobsBuilder ??= new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
var __b = new GitLabCiJobBuilder();
configure(__b);
__JobsBuilder.With(key, __b);
return this;
}
// ── Per-property validation (virtual, overridable) ───────────
protected virtual IEnumerable<Exception>? ValidateSpec(GitLabCiSpec? value)
=> null;
protected virtual IEnumerable<Exception>? ValidateImage(string? value)
=> null;
protected virtual IEnumerable<Exception>? ValidateStages(
List<object>? value) => null;
protected virtual IEnumerable<Exception>? ValidateStagesItem(
object item, int index) => null;
// ... validators for every property ...
// ── ValidateAsync ────────────────────────────────────────────
protected override Task<Result<ValidationResult>> ValidateAsync(
CancellationToken cancellationToken = default)
{
var __result = new ValidationResult();
var __type = typeof(GitLabCiFileBuilder);
foreach (var __err in ValidateSpec(Spec)
?? Array.Empty<Exception>())
__result.AddError(
new MemberName(nameof(Spec), __type), __err);
// ... validation for every property ...
return Task.FromResult(
Result<ValidationResult>.Success(__result));
}
// ── CreateInstance ───────────────────────────────────────────
protected virtual GitLabCiFile CreateInstance()
{
return new GitLabCiFile
{
Spec = Spec,
Image = Image,
Services = Services,
BeforeScript = BeforeScript,
AfterScript = AfterScript,
Variables = Variables,
Cache = Cache,
Default = Default,
Stages = Stages,
Include = Include,
Pages = Pages,
Workflow = Workflow,
Jobs = Jobs,
Extensions = Extensions,
};
}
}public partial class GitLabCiFileBuilder
: global::FrenchExDev.Net.Builder.AbstractBuilder<GitLabCiFile>
{
// ── Input properties ─────────────────────────────────────────
protected GitLabCiSpec? Spec { get; private set; }
public GitLabCiFileBuilder WithSpec(GitLabCiSpec? value)
{ Spec = value; return this; }
protected string? Image { get; private set; }
public GitLabCiFileBuilder WithImage(string? value)
{ Image = value; return this; }
protected global::System.Collections.Generic.List<object>? Stages
{ get; private set; }
public GitLabCiFileBuilder WithStages(
global::System.Collections.Generic.List<object>? value)
{ Stages = value; return this; }
// Dictionary with inline builder
protected global::System.Collections.Generic.Dictionary<string, object?>?
Variables { get; private set; }
public GitLabCiFileBuilder WithVariables(
global::System.Collections.Generic.Dictionary<string, object?>? value)
{ Variables = value; return this; }
public GitLabCiFileBuilder WithVariables(
global::System.Action<global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, object?>> configure)
{
var __b = new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, object?>();
configure(__b);
Variables = __b.Build();
return this;
}
// Jobs dictionary with typed builder
protected global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
Jobs { get; private set; }
public GitLabCiFileBuilder WithJobs(
global::System.Collections.Generic.Dictionary<string, GitLabCiJob>? value)
{ Jobs = value; return this; }
private global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>? __JobsBuilder;
public GitLabCiFileBuilder WithJobs(
global::System.Action<global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>> configure)
{
__JobsBuilder = new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
configure(__JobsBuilder);
return this;
}
public GitLabCiFileBuilder WithJob(string key,
global::System.Action<GitLabCiJobBuilder> configure)
{
__JobsBuilder ??= new global::FrenchExDev.Net.Builder
.DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
var __b = new GitLabCiJobBuilder();
configure(__b);
__JobsBuilder.With(key, __b);
return this;
}
// ── Per-property validation (virtual, overridable) ───────────
protected virtual IEnumerable<Exception>? ValidateSpec(GitLabCiSpec? value)
=> null;
protected virtual IEnumerable<Exception>? ValidateImage(string? value)
=> null;
protected virtual IEnumerable<Exception>? ValidateStages(
List<object>? value) => null;
protected virtual IEnumerable<Exception>? ValidateStagesItem(
object item, int index) => null;
// ... validators for every property ...
// ── ValidateAsync ────────────────────────────────────────────
protected override Task<Result<ValidationResult>> ValidateAsync(
CancellationToken cancellationToken = default)
{
var __result = new ValidationResult();
var __type = typeof(GitLabCiFileBuilder);
foreach (var __err in ValidateSpec(Spec)
?? Array.Empty<Exception>())
__result.AddError(
new MemberName(nameof(Spec), __type), __err);
// ... validation for every property ...
return Task.FromResult(
Result<ValidationResult>.Success(__result));
}
// ── CreateInstance ───────────────────────────────────────────
protected virtual GitLabCiFile CreateInstance()
{
return new GitLabCiFile
{
Spec = Spec,
Image = Image,
Services = Services,
BeforeScript = BeforeScript,
AfterScript = AfterScript,
Variables = Variables,
Cache = Cache,
Default = Default,
Stages = Stages,
Include = Include,
Pages = Pages,
Workflow = Workflow,
Jobs = Jobs,
Extensions = Extensions,
};
}
}NamingHelper: The Snake_Case Bridge
YAML uses snake_case, C# uses PascalCase. The NamingHelper bridges this gap at multiple levels.
PascalCase Conversion
internal static class NamingHelper
{
public static string ToPascalCase(string snakeCase)
{
var sb = new StringBuilder();
var capitalizeNext = true;
foreach (var c in snakeCase)
{
if (c == '_' || c == '-' || c == '.' ||
c == '$' || c == '!' || c == '@')
{
capitalizeNext = true;
continue;
}
if (!char.IsLetterOrDigit(c))
continue;
sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c);
capitalizeNext = false;
}
return sb.ToString();
}
}internal static class NamingHelper
{
public static string ToPascalCase(string snakeCase)
{
var sb = new StringBuilder();
var capitalizeNext = true;
foreach (var c in snakeCase)
{
if (c == '_' || c == '-' || c == '.' ||
c == '$' || c == '!' || c == '@')
{
capitalizeNext = true;
continue;
}
if (!char.IsLetterOrDigit(c))
continue;
sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c);
capitalizeNext = false;
}
return sb.ToString();
}
}This handles GitLab's naming conventions:
before_script→BeforeScriptafter_script→AfterScriptallow_failure→AllowFailureid_tokens→IdTokensstart_in→StartIn$schema→Schema(the$is stripped)!reference→Reference(the!is stripped)
Definition to Class Name Mapping
Schema definition names don't always map cleanly to class names. The DefinitionToClassName method provides an explicit mapping for common definitions and a fallback for unknown ones:
public static string DefinitionToClassName(string definitionName)
{
var pascal = ToPascalCase(definitionName);
return pascal switch
{
"Job" => "GitLabCiJob",
"Image" => "GitLabCiImage",
"Service" => "GitLabCiService",
"Services" => "GitLabCiServices",
"Cache" => "GitLabCiCache",
"Artifacts" => "GitLabCiArtifacts",
"Rules" or "Rule" => "GitLabCiRule",
"Script" => "GitLabCiScript",
"Variables" or "Variable" => "GitLabCiVariables",
"Needs" or "Need" => "GitLabCiNeed",
"Include" => "GitLabCiInclude",
"Secret" or "Secrets" => "GitLabCiSecrets",
"Workflow" => "GitLabCiWorkflow",
"Default" => "GitLabCiDefault",
"Retry" => "GitLabCiRetry",
"Release" => "GitLabCiRelease",
"Environment" => "GitLabCiEnvironment",
"Trigger" => "GitLabCiTrigger",
"Pages" => "GitLabCiPages",
"Inherit" => "GitLabCiInherit",
"IdTokens" or "IdToken" => "GitLabCiIdToken",
"Parallel" => "GitLabCiParallel",
_ => "GitLabCi" + pascal // Fallback: prefix with GitLabCi
};
}public static string DefinitionToClassName(string definitionName)
{
var pascal = ToPascalCase(definitionName);
return pascal switch
{
"Job" => "GitLabCiJob",
"Image" => "GitLabCiImage",
"Service" => "GitLabCiService",
"Services" => "GitLabCiServices",
"Cache" => "GitLabCiCache",
"Artifacts" => "GitLabCiArtifacts",
"Rules" or "Rule" => "GitLabCiRule",
"Script" => "GitLabCiScript",
"Variables" or "Variable" => "GitLabCiVariables",
"Needs" or "Need" => "GitLabCiNeed",
"Include" => "GitLabCiInclude",
"Secret" or "Secrets" => "GitLabCiSecrets",
"Workflow" => "GitLabCiWorkflow",
"Default" => "GitLabCiDefault",
"Retry" => "GitLabCiRetry",
"Release" => "GitLabCiRelease",
"Environment" => "GitLabCiEnvironment",
"Trigger" => "GitLabCiTrigger",
"Pages" => "GitLabCiPages",
"Inherit" => "GitLabCiInherit",
"IdTokens" or "IdToken" => "GitLabCiIdToken",
"Parallel" => "GitLabCiParallel",
_ => "GitLabCi" + pascal // Fallback: prefix with GitLabCi
};
}The GitLabCi prefix prevents name collisions with common types (Default, Cache, Environment, Parallel would all collide with .NET framework types).
Type Mapping
The final piece maps PropertyType values to C# type strings:
public static string MapCSharpType(PropertyModel prop)
{
return prop.Type switch
{
PropertyType.String => "string?",
PropertyType.Integer => "int?",
PropertyType.Number => "double?",
PropertyType.Boolean => "bool?",
PropertyType.StringOrBoolean => "bool?",
PropertyType.StringOrInteger => "int?",
PropertyType.InlineObject when prop.InlineClassName is not null =>
$"{prop.InlineClassName}?",
PropertyType.StringOrObject when prop.OneOfObjectClassName is not null =>
$"{prop.OneOfObjectClassName}?",
PropertyType.Array when prop.Items is not null =>
MapArrayItemType(prop.Items),
PropertyType.Array =>
"global::System.Collections.Generic.List<object>?",
PropertyType.Object =>
"global::System.Collections.Generic.Dictionary<string, object?>?",
PropertyType.Ref when prop.Ref is not null =>
$"{DefinitionToClassName(prop.Ref)}?",
PropertyType.StringOrList =>
"global::System.Collections.Generic.List<string>?",
PropertyType.StringOrObject => "object?",
_ => "object?"
};
}
private static string MapArrayItemType(PropertyModel items)
{
if (items.Type == PropertyType.Ref && items.Ref is not null)
return $"global::System.Collections.Generic" +
$".List<{DefinitionToClassName(items.Ref)}>?";
if (items.Type == PropertyType.InlineObject &&
items.InlineClassName is not null)
return $"global::System.Collections.Generic" +
$".List<{items.InlineClassName}>?";
if (items.Type == PropertyType.String)
return "global::System.Collections.Generic.List<string>?";
if (items.Type == PropertyType.Integer ||
items.Type == PropertyType.StringOrInteger)
return "global::System.Collections.Generic.List<int>?";
if (items.Type == PropertyType.StringOrObject &&
items.OneOfObjectClassName is not null)
return $"global::System.Collections.Generic" +
$".List<{items.OneOfObjectClassName}>?";
return "global::System.Collections.Generic.List<object>?";
}public static string MapCSharpType(PropertyModel prop)
{
return prop.Type switch
{
PropertyType.String => "string?",
PropertyType.Integer => "int?",
PropertyType.Number => "double?",
PropertyType.Boolean => "bool?",
PropertyType.StringOrBoolean => "bool?",
PropertyType.StringOrInteger => "int?",
PropertyType.InlineObject when prop.InlineClassName is not null =>
$"{prop.InlineClassName}?",
PropertyType.StringOrObject when prop.OneOfObjectClassName is not null =>
$"{prop.OneOfObjectClassName}?",
PropertyType.Array when prop.Items is not null =>
MapArrayItemType(prop.Items),
PropertyType.Array =>
"global::System.Collections.Generic.List<object>?",
PropertyType.Object =>
"global::System.Collections.Generic.Dictionary<string, object?>?",
PropertyType.Ref when prop.Ref is not null =>
$"{DefinitionToClassName(prop.Ref)}?",
PropertyType.StringOrList =>
"global::System.Collections.Generic.List<string>?",
PropertyType.StringOrObject => "object?",
_ => "object?"
};
}
private static string MapArrayItemType(PropertyModel items)
{
if (items.Type == PropertyType.Ref && items.Ref is not null)
return $"global::System.Collections.Generic" +
$".List<{DefinitionToClassName(items.Ref)}>?";
if (items.Type == PropertyType.InlineObject &&
items.InlineClassName is not null)
return $"global::System.Collections.Generic" +
$".List<{items.InlineClassName}>?";
if (items.Type == PropertyType.String)
return "global::System.Collections.Generic.List<string>?";
if (items.Type == PropertyType.Integer ||
items.Type == PropertyType.StringOrInteger)
return "global::System.Collections.Generic.List<int>?";
if (items.Type == PropertyType.StringOrObject &&
items.OneOfObjectClassName is not null)
return $"global::System.Collections.Generic" +
$".List<{items.OneOfObjectClassName}>?";
return "global::System.Collections.Generic.List<object>?";
}VersionMetadataEmitter
The final emitter generates a static class with all supported versions and the attribute types used for version annotations:
internal static class VersionMetadataEmitter
{
public static string Emit(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis" +
".ExcludeFromCodeCoverage]");
sb.AppendLine("public static class GitLabCiSchemaVersions");
sb.AppendLine("{");
// Version list
sb.AppendLine(" private static readonly global::System" +
".Collections.Generic.List<string> _versions = new()");
sb.AppendLine(" {");
foreach (var v in schema.Versions)
sb.AppendLine($" \"{v}\",");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static global::System.Collections" +
".Generic.IReadOnlyList<string> Available => _versions;");
sb.AppendLine();
if (schema.Versions.Count > 0)
{
sb.AppendLine($" public static string Latest => " +
$"\"{schema.Versions[schema.Versions.Count - 1]}\";");
sb.AppendLine($" public static string Oldest => " +
$"\"{schema.Versions[0]}\";");
}
sb.AppendLine("}");
sb.AppendLine();
// SinceVersionAttribute
sb.AppendLine("[global::System.AttributeUsage(" +
"global::System.AttributeTargets.Property | " +
"global::System.AttributeTargets.Method | " +
"global::System.AttributeTargets.Class, AllowMultiple = false)]");
sb.AppendLine("public sealed class SinceVersionAttribute " +
": global::System.Attribute");
sb.AppendLine("{");
sb.AppendLine(" public string Version { get; }");
sb.AppendLine(" public SinceVersionAttribute(string version) " +
"=> Version = version;");
sb.AppendLine("}");
sb.AppendLine();
// UntilVersionAttribute
sb.AppendLine("[global::System.AttributeUsage(" +
"global::System.AttributeTargets.Property | " +
"global::System.AttributeTargets.Method | " +
"global::System.AttributeTargets.Class, AllowMultiple = false)]");
sb.AppendLine("public sealed class UntilVersionAttribute " +
": global::System.Attribute");
sb.AppendLine("{");
sb.AppendLine(" public string Version { get; }");
sb.AppendLine(" public UntilVersionAttribute(string version) " +
"=> Version = version;");
sb.AppendLine("}");
return sb.ToString();
}
}internal static class VersionMetadataEmitter
{
public static string Emit(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis" +
".ExcludeFromCodeCoverage]");
sb.AppendLine("public static class GitLabCiSchemaVersions");
sb.AppendLine("{");
// Version list
sb.AppendLine(" private static readonly global::System" +
".Collections.Generic.List<string> _versions = new()");
sb.AppendLine(" {");
foreach (var v in schema.Versions)
sb.AppendLine($" \"{v}\",");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static global::System.Collections" +
".Generic.IReadOnlyList<string> Available => _versions;");
sb.AppendLine();
if (schema.Versions.Count > 0)
{
sb.AppendLine($" public static string Latest => " +
$"\"{schema.Versions[schema.Versions.Count - 1]}\";");
sb.AppendLine($" public static string Oldest => " +
$"\"{schema.Versions[0]}\";");
}
sb.AppendLine("}");
sb.AppendLine();
// SinceVersionAttribute
sb.AppendLine("[global::System.AttributeUsage(" +
"global::System.AttributeTargets.Property | " +
"global::System.AttributeTargets.Method | " +
"global::System.AttributeTargets.Class, AllowMultiple = false)]");
sb.AppendLine("public sealed class SinceVersionAttribute " +
": global::System.Attribute");
sb.AppendLine("{");
sb.AppendLine(" public string Version { get; }");
sb.AppendLine(" public SinceVersionAttribute(string version) " +
"=> Version = version;");
sb.AppendLine("}");
sb.AppendLine();
// UntilVersionAttribute
sb.AppendLine("[global::System.AttributeUsage(" +
"global::System.AttributeTargets.Property | " +
"global::System.AttributeTargets.Method | " +
"global::System.AttributeTargets.Class, AllowMultiple = false)]");
sb.AppendLine("public sealed class UntilVersionAttribute " +
": global::System.Attribute");
sb.AppendLine("{");
sb.AppendLine(" public string Version { get; }");
sb.AppendLine(" public UntilVersionAttribute(string version) " +
"=> Version = version;");
sb.AppendLine("}");
return sb.ToString();
}
}Generated Output
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class GitLabCiSchemaVersions
{
private static readonly global::System.Collections.Generic.List<string>
_versions = new()
{
"18.0.0",
"18.1.0",
"18.2.0",
"18.3.0",
"18.4.0",
"18.5.0",
"18.6.0",
"18.7.0",
"18.8.0",
"18.9.0",
"18.10.0",
};
public static global::System.Collections.Generic.IReadOnlyList<string>
Available => _versions;
public static string Latest => "18.10.0";
public static string Oldest => "18.0.0";
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.GitLab.Ci.Yaml;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class GitLabCiSchemaVersions
{
private static readonly global::System.Collections.Generic.List<string>
_versions = new()
{
"18.0.0",
"18.1.0",
"18.2.0",
"18.3.0",
"18.4.0",
"18.5.0",
"18.6.0",
"18.7.0",
"18.8.0",
"18.9.0",
"18.10.0",
};
public static global::System.Collections.Generic.IReadOnlyList<string>
Available => _versions;
public static string Latest => "18.10.0";
public static string Oldest => "18.0.0";
}