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
45 changes: 31 additions & 14 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlo

C# [top-level programs](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements)
allow a very intuitive, simple and streamlined experience for quickly spiking or learning C#.
The addition of [dotnet run app.cs](https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/) in
.NET 10 takes this further by allowing package references and even MSBuild properties to be
The addition of [file-based apps](https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/) in
.NET 10 [takes this further](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives#file-based-apps) by allowing package references and even MSBuild properties to be
specified per file:

```csharp
#:package Humanizer@2.14.1
#:property LangVersion=preview

using Humanizer;

Expand Down Expand Up @@ -70,42 +71,57 @@ files in subdirectories and those will behave like normal compile items.
## Usage

SmallSharp works by just installing the
[SmallSharp](https://nuget.org/packages/SmallSharp) nuget package in a C# console project.

Recommended installation as an SDK:
[SmallSharp](https://nuget.org/packages/SmallSharp) nuget package in a C# console project
and adding a couple extra properties to the project file:

```xml
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="SmallSharp" Version="2.0.0" />

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>

<!-- 👇 additional properties required in package mode -->
<ImportProjectExtensionProps>true</ImportProjectExtensionProps>
<ImportProjectExtensionTargets>true</ImportProjectExtensionTargets>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SmallSharp" Version="*" PrivateAssets="all" />
</ItemGroup>

</Project>
```

Or as a regular package reference:
If your file-based apps use the `#:sdk` [directive](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives#file-based-apps),
you need to add SmallSharp as an SDK reference instead so the SDK is picked up by the
generated targets/props instead of the project file. You also don't need the additional
properties since the SDK mode sets them automatically for you:

```xml
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="SmallSharp/2.1.0">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SmallSharp" Version="*" />
</ItemGroup>

</Project>
```

> [!IMPORTANT]
> If no `#:sdk` directive is provided by a specific C# file-based app, the `Microsoft.NET.SDK` will be
> used by default in this SDK mode.

Keep adding as many top-level programs as you need, and switch between them easily by simply
selecting the desired file from the Start button dropdown.

When running from the command-line, you can select the file to run by passing it as an argument to `dotnet run`:

```bash
dotnet run -p:ActiveFile program1.cs
```

## How It Works

This nuget package leverages in concert the following standalone and otherwise
Expand All @@ -116,6 +132,7 @@ unrelated features of the compiler, nuget and MSBuild:
3. Whenever changed, the dropdown selection is persisted as the `$(ActiveDebugProfile)` MSBuild property in a file
named after the project with the `.user` extension
4. This file is imported before NuGet-provided MSBuild targets
5. VS ignores `#:` directives when adding the flag `FileBasedProgram` to the `$(Features)` project property.

Using the above features in concert, **SmallSharp** essentially does the following:

Expand All @@ -138,7 +155,7 @@ since the "Main" file selection is performed exclusively via MSBuild item manipu
> [!TIP]
> It is recommended to keep the project file to its bare minimum, usually having just the SmallSharp
> SDK reference, and do all project/package references in the top-level files using the `#:package` and
> `#:property` directives for improved isolation between the top-level programs.
> `#:property` directives for improved isolation between the different file-based apps.

```xml
<Project Sdk="Microsoft.NET.Sdk">
Expand Down
89 changes: 79 additions & 10 deletions src/SmallSharp/EmitTargets.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
Expand All @@ -10,29 +11,52 @@ namespace SmallSharp;

public class EmitTargets : Task
{
static readonly Regex sdkExpr = new(@"^#:sdk\s+([^@]+?)(@(.+))?$");
static readonly Regex packageExpr = new(@"^#:package\s+([^@]+)@(.+)$");
static readonly Regex propertyExpr = new(@"^#:property\s+([^\s]+)\s+(.+)$");

[Required]
public ITaskItem? StartupFile { get; set; }
public required ITaskItem StartupFile { get; set; }

[Required]
public string TargetsFile { get; set; } = "SmallSharp.targets";
public required string BaseIntermediateOutputPath { get; set; }

[Required]
public required string PropsFile { get; set; }

[Required]
public required string TargetsFile { get; set; }

[Required]
public bool UsingSmallSharpSDK { get; set; } = false;

[Output]
public ITaskItem[] Packages { get; set; } = [];

[Output]
public ITaskItem[] Sdks { get; set; } = [];

[Output]
public ITaskItem[] Properties { get; set; } = [];

[Output]
public bool Success { get; set; } = false;

public override bool Execute()
{
if (StartupFile is null)
return false;

var packages = new List<ITaskItem>();
var sdkItems = new List<ITaskItem>();
var propItems = new List<ITaskItem>();

var filePath = StartupFile.GetMetadata("FullPath");
var contents = File.ReadAllLines(filePath);

var items = new List<XElement>();
var properties = new List<XElement>();
var sdks = new List<XAttribute[]>();

foreach (var line in contents)
{
Expand All @@ -50,27 +74,72 @@ public override bool Execute()
new XAttribute("Include", id),
new XAttribute("Version", version)));
}
else if (sdkExpr.Match(line) is { Success: true } sdkMatch)
{
var name = sdkMatch.Groups[1].Value.Trim();
var version = sdkMatch.Groups[2].Value.Trim();
if (!string.IsNullOrEmpty(version))
{
sdkItems.Add(new TaskItem(name, new Dictionary<string, string>
{
{ "Version", version }
}));
sdks.Add([new XAttribute("Sdk", name), new XAttribute("Version", version)]);
}
else
{
sdkItems.Add(new TaskItem(name));
sdks.Add([new XAttribute("Sdk", name)]);
}
}
else if (propertyExpr.Match(line) is { Success: true } propMatch)
{
var name = propMatch.Groups[1].Value.Trim();
var value = propMatch.Groups[2].Value.Trim();

propItems.Add(new TaskItem(name, new Dictionary<string, string>
{
{ "Value", value }
}));
properties.Add(new XElement(name, value));
}
}

Packages = [.. packages];
Sdks = [.. sdkItems];
Properties = [.. propItems];

if (sdks.Count > 0 && !UsingSmallSharpSDK)
{
Log.LogError($"When using #:sdk directive(s), you must use SmallSharp as an SDK: <Project Sdk=\"SmallSharp/{ThisAssembly.Project.Version}\">.");
return false;
}

// We only emit the default SDK if the SmallSharpSDK is in use, since otherwise the
// project file is expected to define its own SDK and we'd be duplicating it.
if (sdks.Count == 0)
sdks.Add([new XAttribute("Sdk", "Microsoft.NET.Sdk")]);

WriteXml(TargetsFile, new XElement("Project",
new XElement("PropertyGroup", properties),
new XElement("ItemGroup", items)
));

WriteXml(Path.Combine(BaseIntermediateOutputPath, "SmallSharp.sdk.props"), new XElement("Project",
sdks.Select(x => new XElement("Import", [new XAttribute("Project", "Sdk.props"), .. x]))));

var doc = new XDocument(
new XElement("Project",
new XElement("PropertyGroup", properties),
new XElement("ItemGroup", items)
)
);
WriteXml(Path.Combine(BaseIntermediateOutputPath, "SmallSharp.sdk.targets"), new XElement("Project",
sdks.Select(x => new XElement("Import", [new XAttribute("Project", "Sdk.targets"), .. x]))));

using var writer = XmlWriter.Create(TargetsFile, new XmlWriterSettings { Indent = true });
doc.Save(writer);
WriteXml(PropsFile, new XElement("Project"));

Success = true;
return true;
}

void WriteXml(string path, XElement root)
{
using var writer = XmlWriter.Create(path, new XmlWriterSettings { Indent = true });
root.Save(writer);
}
}
3 changes: 3 additions & 0 deletions src/SmallSharp/Sdk.Empty.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Project>
<!-- This is needed since the Microsoft.NET.SDK Sdk.props imports the Microsoft.Common.props unconditionally -->
</Project>
21 changes: 16 additions & 5 deletions src/SmallSharp/Sdk.props
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
<Project>

<Import Project="..\build\SmallSharp.props" />

<PropertyGroup>
<ImportProjectExtensionProps>true</ImportProjectExtensionProps>
<ImportProjectExtensionTargets>true</ImportProjectExtensionTargets>

<!-- Since we use this to build StartupFile list and as an SDK, we might not have .NET SDK imported yet (i.e. no C# files at all) -->
<UsingSmallSharpSDK>true</UsingSmallSharpSDK>

<!-- Workaround https://github.com/dotnet/sdk/issues/50573 -->
<AlternateCommonProps>$(MSBuildThisFileDirectory)\Sdk.Empty.props</AlternateCommonProps>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="JsonPoke" Version="1.2.0" PrivateAssets="all" />
</ItemGroup>
<!-- Import Common.props explicitly we're too early here to use MSBuildProjectExtensionsPath -->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="'$(MicrosoftCommonPropsHasBeenImported)' != 'true' and Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />

<Import Project="Sdk.props" Sdk="Microsoft.NET.SDK"
Condition="!Exists('$(MSBuildProjectExtensionsPath)SmallSharp.sdks.props')" />

<Import Project="$(MSBuildProjectExtensionsPath)SmallSharp.sdks.props"
Condition="Exists('$(MSBuildProjectExtensionsPath)SmallSharp.sdks.props')" />

<Import Project="..\build\SmallSharp.props" />

</Project>
28 changes: 28 additions & 0 deletions src/SmallSharp/Sdk.targets
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
<Project>

<Import Project="Sdk.targets" Sdk="Microsoft.NET.SDK"
Condition="!Exists('$(MSBuildProjectExtensionsPath)SmallSharp.sdks.targets')" />

<Import Project="$(MSBuildProjectExtensionsPath)SmallSharp.sdks.targets"
Condition="Exists('$(MSBuildProjectExtensionsPath)SmallSharp.sdks.targets')" />

<Import Project="..\build\SmallSharp.targets" />

<Target Name="ImplicitPackageReferenceFromStartupFile" BeforeTargets="_GenerateProjectRestoreGraphPerFramework"
DependsOnTargets="StartupFile"
Condition="'$(StartupFile)' != '' and Exists('$(StartupFile)') and '$(RestoreNeeded)' == 'true'" >

<!-- Optimize for restore success on first run without previously running our targets -->
<ReadLinesFromFile File="$(StartupFile)">
<Output TaskParameter="Lines" ItemName="_StartupFileLines" />
</ReadLinesFromFile>

<ItemGroup>
<_PkgLines Include="@(_StartupFileLines)"
Condition="$([MSBuild]::ValueOrDefault('%(Identity)', '').StartsWith('#:package '))" />

<_PkgReference Include="$([MSBuild]::ValueOrDefault('%(_PkgLines.Identity)', '').Substring(10))" />

<PackageReference Condition="'@(_PkgReference)' != ''" Include="$([MSBuild]::ValueOrDefault('%(_PkgReference.Identity)', '').Split('@')[0])">
<Version>$([MSBuild]::ValueOrDefault('%(_PkgReference.Identity)', '').Split('@')[1])</Version>
</PackageReference>
</ItemGroup>

</Target>

</Project>
14 changes: 10 additions & 4 deletions src/SmallSharp/SmallSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="NuGetizer" Version="1.3.0" />
<PackageReference Include="JsonPoke" Version="1.2.0" />
<PackageReference Include="Microsoft.Build.Tasks.Core" Version="17.13.26" Pack="false" />
<PackageReference Include="NuGetizer" Version="1.3.0" />
<PackageReference Include="JsonPoke" Version="1.2.0" Pack="false" GeneratePathProperty="true" />
<PackageReference Include="NuGet.Versioning" Version="6.14.0" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Project" Version="2.0.14" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<None Include="..\_._" PackFolder="lib\netstandard2.0" Visible="false" />
<None Update="SmallSharp.targets" PackFolder="$(PackFolder)" CopyToOutputDirectory="PreserveNewest" />
<None Update="SmallSharp.Before.targets" PackFolder="$(PackFolder)" CopyToOutputDirectory="PreserveNewest" />
<None Update="Sdk.props;Sdk.targets" PackFolder="Sdk" CopyToOutputDirectory="PreserveNewest" />
<None Update="Sdk.*" PackFolder="Sdk" CopyToOutputDirectory="PreserveNewest" />
<None Include="$(PkgJsonPoke)\build\JsonPoke.dll" PackFolder="$(PackFolder)" CopyToOutputDirectory="PreserveNewest" Visible="false" />
<None Include="$(PkgJsonPoke)\build\Newtonsoft.Json.dll" PackFolder="$(PackFolder)" CopyToOutputDirectory="PreserveNewest" Visible="false" />
</ItemGroup>

<ItemGroup>
<UpToDateCheckInput Include="SmallSharp.targets;SmallSharp.Before.targets;Sdk.props;Sdk.targets" />
<UpToDateCheckInput Include="SmallSharp.targets;SmallSharp.Before.targets;Sdk.props;Sdk.Empty.props;Sdk.targets" />
<ProjectProperty Include="PackageVersion" />
</ItemGroup>

</Project>
4 changes: 3 additions & 1 deletion src/SmallSharp/SmallSharp.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

<!-- Capture project-level properties before they are defaulted by Microsoft.Common.targets -->
<CustomBeforeMicrosoftCSharpTargets>$(MSBuildThisFileDirectory)\SmallSharp.Before.props</CustomBeforeMicrosoftCSharpTargets>
</PropertyGroup>

<UsingSmallSharpSDK Condition="'$(UsingSmallSharpSDK)' == ''">false</UsingSmallSharpSDK>
</PropertyGroup>

</Project>
Loading