Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ jobs:
- name: Test
run: dotnet test Scribe.slnx --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"

- name: Pack
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: dotnet pack Scribe.slnx --configuration Release --no-build --output .artifacts/packages

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
Expand Down
19 changes: 1 addition & 18 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@
that builds and packs in isolation.
============================================================ -->

<PropertyGroup>
<IsLocalBuild>true</IsLocalBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<IsLocalBuild>false</IsLocalBuild>
</PropertyGroup>

<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
Expand Down Expand Up @@ -55,17 +51,7 @@
Version is managed by Nerdbank.GitVersioning (version.json).
============================================================ -->
<PropertyGroup>
<!-- Pack on CI (all configs) or on an explicit local Release build.
Skip packing during local Debug to keep iteration fast. -->
<GeneratePackageOnBuild
Condition="'$(IsLocalBuild)' == 'false' or '$(Configuration)' == 'Release'"
>true</GeneratePackageOnBuild
>
<GeneratePackageOnBuild
Condition="'$(IsLocalBuild)' == 'true' and '$(Configuration)' != 'Release'"
>false</GeneratePackageOnBuild
>
<!-- Override the artifacts-computed package folder with a flat, well-known path. -->
<!-- CI uses explicit `dotnet pack` in the pipeline. No auto-pack needed. -->
<PackageOutputPath>$(ArtifactsPath)packages/</PackageOutputPath>
<Authors>Max Obrist</Authors>
<Company>Bullets for Humanity</Company>
Expand All @@ -74,9 +60,6 @@
<RepositoryUrl>https://github.com/BulletsForHumanity/Scribe</RepositoryUrl>
<PackageProjectUrl>https://github.com/BulletsForHumanity/Scribe</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Symbols: produce a .snupkg alongside each .nupkg so consumers can step through source. -->
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- SourceLink: embed a pointer to the exact commit on GitHub so debuggers can fetch source. -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
Expand Down
24 changes: 1 addition & 23 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
<Project>
<!--
On local (non-CI) builds, append -dev to the package version so locally produced
packages are never version-identical to NuGet.org releases. The condition guards
against double-suffixing: NBGV already adds a git-hash pre-release tag on
non-public branches, so we only append -dev when the version is still clean.
-->
<Target
Name="AppendDevVersionSuffix"
AfterTargets="GetBuildVersion"
Condition="'$(IsLocalBuild)' == 'true' and '$(IsPackable)' != 'false'"
>
<PropertyGroup>
<!-- Timestamp gives a unique, always-incrementing counter per build without needing a counter file.
Format: yyyyMMddHHmmss — a valid SemVer2 numeric identifier that sorts chronologically. -->
<_DevBuildStamp>$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</_DevBuildStamp>
<PackageVersion Condition="!$(PackageVersion.Contains('-'))"
>$(PackageVersion)-dev.$(_DevBuildStamp)</PackageVersion
>
<NuGetPackageVersion Condition="!$(NuGetPackageVersion.Contains('-'))"
>$(NuGetPackageVersion)-dev.$(_DevBuildStamp)</NuGetPackageVersion
>
</PropertyGroup>
</Target>
<!-- Intentionally empty — stops MSBuild from walking up to parent directories. -->
</Project>
Comment on lines 1 to 3

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is titled as a Quill using-resolution bug fix, but it also removes local version-suffixing/auto-pack logic (Directory.Build.props/targets) and changes CI to run dotnet pack on pushes. If these build/pipeline changes are required, consider documenting the rationale in the PR description or splitting into a separate PR to keep the scope focused.

Copilot uses AI. Check for mistakes.
118 changes: 48 additions & 70 deletions Scribe.Tests/QuillTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,11 +560,16 @@ public void ContentResult_ImplicitConversion_AllowsFluent()
}

// ── Global Reference Resolution ────────────────────────────────────
//
// ResolveGlobalReferences only shortens global:: references whose namespace
// is already registered via Using()/Usings(). Unregistered global:: refs
// stay as-is — the generator author must register the namespaces they need.

[Fact]
public void Global_ResolvesGlobalRefs_ToShortNames()
{
var q = new Quill();
q.Using("System.Collections.Generic");
q.Line("var x = new global::System.Collections.Generic.List<int>();");
var result = q.Inscribe();

Expand All @@ -577,6 +582,7 @@ public void Global_ResolvesGlobalRefs_ToShortNames()
public void Global_ResolvesMultipleDistinctRefs()
{
var q = new Quill();
q.Usings("System", "System.Text.Json");
q.Line("global::System.Guid id = default;");
q.Line("global::System.Text.Json.JsonSerializer.Serialize(id);");
var result = q.Inscribe();
Expand All @@ -589,37 +595,21 @@ public void Global_ResolvesMultipleDistinctRefs()
}

[Fact]
public void Global_ConflictingTypeNames_CreatesAliases()
public void Global_UnregisteredNamespace_StaysAsIs()
{
var q = new Quill();
q.Line("global::Foo.Bar.Widget w1 = default;");
q.Line("global::Baz.Qux.Widget w2 = default;");
// No Using() call — global:: should stay
q.Line("global::Foo.Bar.Widget w = default;");
var result = q.Inscribe();

result.ShouldNotContain("global::");
result.ShouldContain("BarWidget w1 = default;");
result.ShouldContain("QuxWidget w2 = default;");
result.ShouldContain("using BarWidget = Foo.Bar.Widget;");
result.ShouldContain("using QuxWidget = Baz.Qux.Widget;");
}

[Fact]
public void Global_ConflictingTypeNames_WalksUpNamespace_UntilUnique()
{
var q = new Quill();
q.Line("global::A.Utils.Helper h1 = default;");
q.Line("global::B.Utils.Helper h2 = default;");
var result = q.Inscribe();

result.ShouldNotContain("global::");
result.ShouldContain("AUtilsHelper h1 = default;");
result.ShouldContain("BUtilsHelper h2 = default;");
result.ShouldContain("global::Foo.Bar.Widget w = default;");
}

[Fact]
public void Global_DeduplicatesSameTypeUsedMultipleTimes()
{
var q = new Quill();
q.Using("System");
q.Line("global::System.Guid a = default;");
q.Line("global::System.Guid b = default;");
var result = q.Inscribe();
Expand All @@ -636,7 +626,7 @@ public void Global_DeduplicatesSameTypeUsedMultipleTimes()
public void Global_PreservesManualUsings_AlongsideResolved()
{
var q = new Quill();
q.Using("System.Linq");
q.Usings("System", "System.Linq");
q.Line("global::System.Guid id = default;");
var result = q.Inscribe();

Expand All @@ -657,36 +647,37 @@ public void Global_NoGlobalRefs_DoesNotAddUsings()
}

[Fact]
public void Global_MultipleRefsResolved_WithoutRegistration()
public void Global_MemberAccess_ResolvedCorrectlyWithUsing()
{
// global::System.Globalization.CultureInfo.InvariantCulture — CultureInfo is a type,
// InvariantCulture is a static property. With "System.Globalization" registered,
// the global:: prefix is stripped and the remainder is CultureInfo.InvariantCulture.
var q = new Quill();
q.Line("global::System.Guid id = default;");
q.Line("global::System.Text.Json.JsonSerializer.Serialize(id);");
q.Using("System.Globalization");
q.Line("var c = global::System.Globalization.CultureInfo.InvariantCulture;");
var result = q.Inscribe();

result.ShouldContain("using System;");
result.ShouldContain("using System.Text.Json;");
result.ShouldContain("using System.Globalization;");
result.ShouldContain("var c = CultureInfo.InvariantCulture;");
result.ShouldNotContain("global::");
}

[Fact]
public void Global_MemberAccess_UseUsingForChainedAccess()
public void Global_MemberAccess_WithoutUsing_StaysAsIs()
{
// Auto-discovery can't distinguish type boundaries from member access chains.
// For global::Type.Property.Method(...) patterns, use Using() + short type name instead.
// Without a registered using, global:: stays — no guessing.
var q = new Quill();
q.Using("System");
q.Line("return StringComparer.Ordinal.GetHashCode(Key);");
q.Line("var c = global::System.Globalization.CultureInfo.InvariantCulture;");
var result = q.Inscribe();

result.ShouldContain("using System;");
result.ShouldContain("return StringComparer.Ordinal.GetHashCode(Key);");
result.ShouldContain("global::System.Globalization.CultureInfo.InvariantCulture");
}

[Fact]
public void Global_InGenericTypeArgument_ResolvesCorrectly()
{
var q = new Quill();
q.Usings("System", "System.Collections.Generic");
q.Line("var items = new global::System.Collections.Generic.List<global::System.Guid>();");
var result = q.Inscribe();

Expand All @@ -700,6 +691,7 @@ public void Global_InGenericTypeArgument_ResolvesCorrectly()
public void Global_InNestedGenerics_ResolvesAll()
{
var q = new Quill();
q.Using("System.Collections.Generic");
q.Line("var x = new global::System.Collections.Generic.Dictionary<string, global::System.Collections.Generic.List<int>>();");
var result = q.Inscribe();

Expand All @@ -712,6 +704,7 @@ public void Global_InNestedGenerics_ResolvesAll()
public void Global_InInterfaceList_ResolvesCorrectly()
{
var q = new Quill();
q.Using("System");
q.Line("public partial record Amount : global::System.IParsable<Amount>, global::System.IComparable<Amount>");
var result = q.Inscribe();

Expand All @@ -726,6 +719,7 @@ public void Global_InInterfaceList_ResolvesCorrectly()
public void Global_NullableType_ResolvesCorrectly()
{
var q = new Quill();
q.Using("System");
q.Line("public static bool TryParse(string? s, global::System.IFormatProvider? provider, out Amount result)");
var result = q.Inscribe();

Expand All @@ -738,6 +732,7 @@ public void Global_NullableType_ResolvesCorrectly()
public void Global_InConstructor_ResolvesCorrectly()
{
var q = new Quill();
q.Using("System");
q.Line("throw new global::System.ArgumentException(\"bad\", nameof(value));");
var result = q.Inscribe();

Expand All @@ -750,6 +745,7 @@ public void Global_InConstructor_ResolvesCorrectly()
public void Global_InTypeof_ResolvesCorrectly()
{
var q = new Quill();
q.Using("System");
q.Line("if (type == typeof(global::System.Guid))");
var result = q.Inscribe();

Expand All @@ -761,6 +757,7 @@ public void Global_InTypeof_ResolvesCorrectly()
public void Global_SameNamespaceMultipleTypes_SingleUsing()
{
var q = new Quill();
q.Using("System");
q.Line("global::System.Guid id = default;");
q.Line("throw new global::System.ArgumentException(\"bad\");");
q.Line("global::System.IntPtr ptr = default;");
Expand All @@ -773,50 +770,28 @@ public void Global_SameNamespaceMultipleTypes_SingleUsing()
}

[Fact]
public void Global_ThreeWayConflict_DisambiguatesAll()
{
var q = new Quill();
q.Line("global::A.X.Config a = default;");
q.Line("global::B.Y.Config b = default;");
q.Line("global::C.Z.Config c = default;");
var result = q.Inscribe();

result.ShouldNotContain("global::");
// All three should have unique aliases
result.ShouldContain("XConfig a = default;");
result.ShouldContain("YConfig b = default;");
result.ShouldContain("ZConfig c = default;");
result.ShouldContain("using XConfig = A.X.Config;");
result.ShouldContain("using YConfig = B.Y.Config;");
result.ShouldContain("using ZConfig = C.Z.Config;");
}

[Fact]
public void Global_ConflictAndNonConflict_MixedCorrectly()
public void Global_LongestNamespaceMatchWins()
{
// When both "System" and "System.Text.Json" are registered,
// "global::System.Text.Json.JsonSerializer" should match the longer namespace.
var q = new Quill();
q.Line("global::System.Guid id = default;");
q.Line("global::Foo.Widget a = default;");
q.Line("global::Bar.Widget b = default;");
q.Usings("System", "System.Text.Json");
q.Line("global::System.Text.Json.JsonSerializer.Serialize(id);");
var result = q.Inscribe();

result.ShouldContain("JsonSerializer.Serialize(id);");
// Should NOT produce "Text.Json.JsonSerializer" from matching "System" only
result.ShouldNotContain("Text.Json.JsonSerializer");
result.ShouldNotContain("global::");
result.ShouldContain("using System;");
result.ShouldContain("Guid id = default;");
result.ShouldContain("FooWidget a = default;");
result.ShouldContain("BarWidget b = default;");
result.ShouldContain("using FooWidget = Foo.Widget;");
result.ShouldContain("using BarWidget = Bar.Widget;");
}

[Fact]
public void Global_InMultiLineBlock_ResolvesAcrossLines()
{
var q = new Quill();
q.Using("System.Threading.Tasks");
q.Usings("System.Threading", "System.Threading.Tasks");
using (q.Block("public global::System.Threading.Tasks.Task RunAsync(global::System.Threading.CancellationToken cancellationToken)"))
{
// Use short name for member access chains (Task.CompletedTask)
q.Line("return Task.CompletedTask;");
}

Expand All @@ -830,24 +805,25 @@ public void Global_InMultiLineBlock_ResolvesAcrossLines()
}

[Fact]
public void Global_WithStaticPropertyAccess_UseUsingForEnumMembers()
public void Global_WithStaticPropertyAccess_ResolvesWithRegisteredUsing()
{
// global::System.StringComparison.Ordinal is ambiguous — the scanner can't tell
// that StringComparison is the type and Ordinal is the enum value.
// Use Using() + short name for these patterns.
// With the namespace registered, global::System.StringComparison.Ordinal
// correctly becomes StringComparison.Ordinal.
var q = new Quill();
q.Using("System");
q.Line("string.Equals(a, b, StringComparison.Ordinal);");
q.Line("string.Equals(a, b, global::System.StringComparison.Ordinal);");
var result = q.Inscribe();

result.ShouldContain("using System;");
result.ShouldContain("string.Equals(a, b, StringComparison.Ordinal);");
result.ShouldNotContain("global::");
}

[Fact]
public void Global_UsingsAreSorted()
{
var q = new Quill();
q.Usings("System", "System.Collections.Generic", "System.Threading.Tasks");
q.Line("global::System.Threading.Tasks.Task t = default;");
q.Line("global::System.Collections.Generic.List<int> l = default;");
q.Line("global::System.Guid g = default;");
Expand Down Expand Up @@ -927,6 +903,7 @@ public void Alias_ExplicitAlias_ReturnValue_IsAliasName()
public void Alias_CoexistsWithGlobalResolution()
{
var q = new Quill();
q.Using("System");
var w = q.Alias("Foo.Bar", "Widget", "FooWidget");
q.Line($"{w} a = default;");
q.Line("global::System.Guid id = default;");
Expand All @@ -945,6 +922,7 @@ public void Alias_CoexistsWithGlobalResolution()
public void FullIntegration_ProducesCorrectOutput()
{
var q = new Quill();
q.Using("System");
q.FileNamespace("TestDomain");

using (q.Block("public partial record Amount : global::System.IParsable<Amount>"))
Expand Down
Loading
Loading