Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ project.lock.json
*.userosscache
*.sln.docstates

# install.sh WASM staging output
FadeBasic/FadeBasic.Export.Web/staging/

# Build results (scoped to .NET bin/obj so they don't match source folders named debug/release)
**/bin/[Dd]ebug/
**/bin/[Dd]ebugPublic/
Expand All @@ -27,6 +30,8 @@ project.lock.json
x64/
x86/
build/
!FadeBasic/FadeBasic.Export.Web/build/
!FadeBasic/FadeBasic.Export.Web/build/**
bld/
[Bb]in/
[Oo]bj/
Expand All @@ -40,4 +45,7 @@ msbuild.wrn

# Plugin signing material (JetBrains Marketplace)
*.pem
*.crt
*.crt

# BenchmarkDotNet output
**/BenchmarkDotNet.Artifacts/
3 changes: 2 additions & 1 deletion FadeBasic/ApplicationSupport/ApplicationSupport.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

<ItemGroup>
<ProjectReference Include="..\FadeBasic\FadeBasic.csproj" />
<ProjectReference Include="..\LSP.Core\FadeBasic.LSP.Core.csproj" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="DocSite\index.html" LogicalName="docs/index.html"/>
<EmbeddedResource Include="DocSite\styles.css" LogicalName="docs/styles.css"/>
Expand Down
85 changes: 71 additions & 14 deletions FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,59 @@ public class LaunchableGenerator
public const string TAG_MAIN = "__MAIN__";
public const string TAG_ENCODED_BYTECODE = "__ENCODED_BYTE_CODE__";
public const string TAG_ENCODED_DEBUGDATA = "__ENCODED_DEBUG_DATA__";
public const string TAG_ENCODED_TESTMANIFEST = "__ENCODED_TEST_MANIFEST__";
public const string TAG_COMMAND_ARRAY = "__COMMAND_ARR__";
public const string TEMPLATE_BYTECODE_TAB = " ";
public const string TEMPLATE_ENCODED_BYTE_VAR = "encodedByteCode";
public const string TEMPLATE_ENCODED_DEBUGDATA_VAR = "encodedDebugData";
public const string TEMPLATE_ENCODED_TESTMANIFEST_VAR = "encodedTestManifest";
public const string TEMPLATE_BYTECODE_VAR = "_byteCode";
public const string TEMPLATE_DEBUGDATA_VAR = "_debugData";
public const string TEMPLATE_TESTMANIFEST_VAR = "_testManifest";

// Default Main when FadeEnableTesting is off. Forwards args into the
// existing test-aware Launcher dispatcher (handles --fade-test=name etc.).
public static readonly string MainTemplate =
$@"
public static void Main(string[] args)
public static int Main(string[] args)
{{
Launcher.Run<{TAG_CLASSNAME}>();
return Launcher.Main<{TAG_CLASSNAME}>(args);
}}
";
public static readonly string ClassTemplate =

// Main when FadeEnableTesting is on. Routes Microsoft.Testing.Platform
// invocations (dotnet test, --list-tests, --filter, --server, ...) through
// FadeBasic.Testing.FadeTestApplicationBuilder; everything else still goes
// to the existing Launcher path so `dotnet run` and --fade-test keep working.
//
// Custom IFadeTestHost is picked up by attribute-based discovery: tag the
// class [FadeBasic.Testing.FadeTestHost] and FadeTestApplicationBuilder
// resolves it at startup. If none is found, DefaultFadeTestHost is used.
public static readonly string MainTemplateWithTesting =
$@"
public static int Main(string[] args)
{{
if (global::FadeBasic.Testing.FadeTestApplicationBuilder.IsTestInvocation(args))
{{
var instance = new {TAG_CLASSNAME}();
return global::FadeBasic.Testing.FadeTestApplicationBuilder
.RunAsync(instance, args)
.GetAwaiter().GetResult();
}}
return Launcher.Main<{TAG_CLASSNAME}>(args);
}}
";

public static readonly string ClassTemplate =
$@"// This is a generated file. Do not edit directly.

using {nameof(System)};
using {nameof(System)}.{nameof(System.Collections)}.{nameof(System.Collections.Generic)};
using {nameof(FadeBasic)};
using {nameof(FadeBasic)}.{nameof(FadeBasic.Launch)};
using {nameof(FadeBasic)}.{nameof(FadeBasic.Virtual)};

public class {TAG_CLASSNAME} : {nameof(ILaunchable)}
public partial class {TAG_CLASSNAME} : {nameof(ITestLaunchable)}
{{
{TAG_MAIN}

Expand All @@ -49,6 +79,8 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)}

public DebugData DebugData => {TEMPLATE_DEBUGDATA_VAR};

public IReadOnlyList<TestManifestEntry> TestManifest => {TEMPLATE_TESTMANIFEST_VAR};

#region method table
private static readonly CommandCollection _collection = new CommandCollection(
{TAG_COMMAND_ARRAY}
Expand All @@ -64,37 +96,61 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)}
protected byte[] {TEMPLATE_BYTECODE_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.Unpack64)}({TEMPLATE_ENCODED_BYTE_VAR});
protected const string {TEMPLATE_ENCODED_BYTE_VAR} = {TAG_ENCODED_BYTECODE};
#endregion

#region testManifest
protected IReadOnlyList<TestManifestEntry> {TEMPLATE_TESTMANIFEST_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.UnpackTestManifest)}({TEMPLATE_ENCODED_TESTMANIFEST_VAR});
protected const string {TEMPLATE_ENCODED_TESTMANIFEST_VAR} = {TAG_ENCODED_TESTMANIFEST};
#endregion
}}
";

public static void GenerateLaunchable(string className,
string filePath,
CodeUnit unit,
CommandCollection collection,
List<string> commandClasses,
public static void GenerateLaunchable(string className,
string filePath,
CodeUnit unit,
CommandCollection collection,
List<string> commandClasses,
bool includeMain=true,
bool generateDebug=false)
bool generateDebug=false,
bool enableTesting=false)
{
var compiler = unit.program.Compile(collection, new CompilerOptions
{
GenerateDebugData = generateDebug
});

// Stamp originating .fbasic file paths onto each test manifest entry
// before we pack it into the generated launchable. Multi-file projects
// need this so IDE Test Explorer (Stage 11H VSTest adapter) can
// source-link each test to the right file. CodeUnit always carries a
// SourceMap when it comes from the build-task / SDK pipelines.
FadeBasic.Launch.LaunchUtil.ApplySourceMap(compiler.TestManifest, unit.sourceMap);

var byteCode = compiler.Program.ToArray();
var src = ClassTemplate;

var byteCodeStr = LaunchUtil.Pack64(byteCode);
string byteCodeReplacement = "\"" + byteCodeStr + "\"";
var commandArray = GetCommandTable(commandClasses);


var debugDataStr = generateDebug ? LaunchUtil.PackDebugData(compiler.DebugData) : "";
string debugDataReplacement = "\"" + debugDataStr + "\"";

var main = includeMain ? MainTemplate : "";
src = src.Replace(TAG_MAIN, main);

// Always pack the test manifest. Empty when the source has no tests.
var testManifestStr = LaunchUtil.PackTestManifest(compiler.TestManifest);
string testManifestReplacement = "\"" + testManifestStr + "\"";

string mainBlock = "";
if (includeMain)
{
mainBlock = enableTesting ? MainTemplateWithTesting : MainTemplate;
}

src = src.Replace(TAG_MAIN, mainBlock);
src = src.Replace(TAG_COMMAND_ARRAY, commandArray);
src = src.Replace(TAG_ENCODED_BYTECODE, byteCodeReplacement);
src = src.Replace(TAG_ENCODED_DEBUGDATA, debugDataReplacement);
src = src.Replace(TAG_ENCODED_TESTMANIFEST, testManifestReplacement);
src = src.Replace(TAG_CLASSNAME, className);

var dir = Path.GetDirectoryName(filePath);
Expand All @@ -111,6 +167,7 @@ static string GetCommandTable(List<string> commandClasses)
}
return string.Join(", ", instantiates);
}

static string GetCommandTable(ProjectContext context)
{
// IMethod collection = new CommandCollection()
Expand Down
49 changes: 25 additions & 24 deletions FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,39 +239,40 @@ public static (ProjectCommandInfo, AssemblyLoadContext) LoadCommands(string libP
var sources = new List<IMethodSource>();
var loadContext = new AssemblyLoadContext("metadata", isCollectible: true);

// TODO: technically there could be multiple lib paths, right? one for each library?
// var libPath = Path.GetDirectoryName(libraries[0].absoluteOutputDllPath);
// var libPath = AppContext.BaseDirectory;

// Probe the consumer's TargetDir (libPath) AND the directory of each
// loaded library DLL. A macro command in lib A can call into a sibling
// assembly B that A project-references; B sits next to A in A's own bin
// (or in the NuGet lib/ folder), but on a clean build it has not yet
// been copied into the consumer's TargetDir when this resolver runs.
var probeDirs = new List<string> { libPath };
foreach (var lib in libraries)
{
var dir = Path.GetDirectoryName(lib.absoluteOutputDllPath);
if (!string.IsNullOrEmpty(dir) && !probeDirs.Contains(dir))
probeDirs.Add(dir);
}

loadContext.Resolving += (assemblyContext, assemblyName) =>
{
if (assemblyName.FullName == typeof(IMethodSource).Assembly.GetName().FullName)
{
// log("!!! Trying to load common assembly.");
return typeof(IMethodSource).Assembly;
}


// log($"!!! Requested: [{assemblyName.FullName}]");
//log($"!!! Compared: [{typeof(IMethodSource).Assembly.GetName().FullName}]");

string candidatePath = Path.Combine(
libPath,
assemblyName.Name + ".dll");

// log($"!!! candidate-path=[{candidatePath}]");

if (!File.Exists(candidatePath))
return null;
foreach (var dir in probeDirs)
{
var candidatePath = Path.Combine(dir, assemblyName.Name + ".dll");
if (!File.Exists(candidatePath))
continue;

var foundName = AssemblyName.GetAssemblyName(candidatePath);
// log($"!!! candidate-name=[{foundName.Name}] vs requested=[{assemblyName.Name}]");

if (foundName.Name != assemblyName.Name)
return null;
var foundName = AssemblyName.GetAssemblyName(candidatePath);
if (foundName.Name != assemblyName.Name)
continue;

return assemblyContext.LoadIntoMemory(candidatePath);
}

// log($"!!! Proxied: [{candidatePath}]");
return assemblyContext.LoadIntoMemory(candidatePath);
return null;
};
using var _ = loadContext.EnterContextualReflection();

Expand Down
59 changes: 55 additions & 4 deletions FadeBasic/ApplicationSupport/Project/ProjectDocs.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Xml.Linq;

Expand Down Expand Up @@ -310,21 +312,44 @@ public static void ParseBlock(IDocParser parser, XElement summary, StringBuilder
public static ProjectDocs LoadDocs<T>(this List<CommandMetadata> metadatas, Action<string, System.Xml.XmlException> onDocParseError = null)
where T : IDocParser, new()
{
// Build command name -> group lookup so <see cref="x"/> can resolve links
// Two lookups so <see cref="X"/> can resolve from either the
// Fade-script call name (`"texture"`) OR the underlying C# method
// name (`"LoadTexture"`). The XML docs in our source use both forms
// freely; without the second map the C# names fall through to the
// inline-code fallback in MarkdownDocParser.ConvertSee and never
// produce a clickable link.
var commandToGroup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var methodNameToCallName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadata in metadatas)
{
foreach (var command in metadata.commands)
{
commandToGroup[command.callName] = metadata.className;
var shortMethodName = ExtractShortMethodName(command.methodName, command.sig);
if (!string.IsNullOrEmpty(shortMethodName) && !methodNameToCallName.ContainsKey(shortMethodName))
methodNameToCallName[shortMethodName] = command.callName;
}
}

var parser = new T();
// Fragment-style URL the playground intercepts in help.ts and routes
// through helpCtl.selectCommand. The browser treats `#…` as
// same-page navigation, so a stray middle-click / new-tab doesn't
// 404 against a path that nothing serves. The fragment's payload
// is URI-encoded so callNames with spaces (`"push asset"`) survive.
parser.ResolveSeeRef = cref =>
{
if (commandToGroup.TryGetValue(cref, out var group))
return "/command/" + group + "/" + cref;
if (string.IsNullOrEmpty(cref)) return null;
// Strip any trailing `(...)` so `<see cref="Sync()">` matches the
// bare `Sync` we have in the methodName map. The XML source uses
// both forms; both should link.
var key = cref;
var paren = key.IndexOf('(');
if (paren > 0) key = key.Substring(0, paren);
if (methodNameToCallName.TryGetValue(key, out var callName))
return "#fade-cmd:" + Uri.EscapeDataString(callName);
if (commandToGroup.ContainsKey(key))
return "#fade-cmd:" + Uri.EscapeDataString(key);
return null;
};

Expand All @@ -340,7 +365,14 @@ public static ProjectDocs LoadDocs<T>(this List<CommandMetadata> metadatas, Acti
{
var doc = new CommandDocs();
group.commands.Add(doc);
docs.map[command.sig] = doc;
// Key by callName + sig — `command.sig` alone is only the
// type signature (e.g. "voidR9"), shared by every command
// with the same return type and arg shape. Without callName
// in the key, two commands with the same sig clobber each
// other in this map and Lookup returns the wrong CommandDocs
// (or none, if a later command overwrote the slot). Match
// CommandInfo.UniqueName on the lookup side.
docs.map[command.callName + command.sig] = doc;
doc.command = command;
doc.commandName = command.callName;
doc.methodDocs = ParseMethodDocs(parser, command.docString, ex =>
Expand All @@ -353,6 +385,25 @@ public static ProjectDocs LoadDocs<T>(this List<CommandMetadata> metadatas, Acti
return docs;
}

// The source generator emits `MethodName = "Call_<short>_<sig>"` per
// command. Crefs in the XML docs reference the underlying C# method
// ("Push", "LoadTexture") rather than the generated wrapper, so we
// strip the `Call_` prefix and `_<sig>` suffix to recover the short
// name we can match cref keys against.
internal static string ExtractShortMethodName(string methodName, string sig)
{
if (string.IsNullOrEmpty(methodName)) return null;
const string prefix = "Call_";
if (!methodName.StartsWith(prefix, StringComparison.Ordinal)) return methodName;
var stripped = methodName.Substring(prefix.Length);
if (!string.IsNullOrEmpty(sig))
{
var suffix = "_" + sig;
if (stripped.EndsWith(suffix, StringComparison.Ordinal))
stripped = stripped.Substring(0, stripped.Length - suffix.Length);
}
return stripped;
}
}

public class ProjectDocs
Expand Down
Loading
Loading