Implementing custom #load behavior in Roslyn scripting

Β· 1332 words Β· 7 minutes to read

#load directives in C# scripts are intended to allow you to reference a C# script source file from another C# script. As an author of a host application, in which the Roslyn scripting would be embedded, it’s up to you to define how #load should behave.

Let’s have a look at the process of doing that.

Background πŸ”—

C# scripting, since its early beginnings (been trying to find the old white paper by Bill Chiles where it was mentioned, but I seem to have displaced it), contained the concept of #load preprocessor directives.

In the early Roslyn previews (2011, 2012 and so on), Roslyn itself didn’t support for it though, instead in the scriptcs project, we implemented our own version of this, where we’d recurse through reference files, resolve their references and compile them into one compilation unit so that your script can be run successfully.

This changed with the stable release of Roslyn and for a while now, Roslyn has had built-in support for #load, which you can actually customize in terms of its behavior. This is really important feature, because while scriptcs traditionally allowed you to define and implement custom preprocesssor directives (i.e. #gist, #sql and so on), Roslyn wants you to use #load to cover all of these cases.

Source resolvers πŸ”—

In order to introduce your custom #load logic, you will have to implement an abstract class SourceReferenceResolver. It defines the necessary methods Roslyn will call when trying to resolve the #load directives it encounters in script code.

The class is shown below (mainly just for reference):

    /// <summary>

    /// Resolves references to source documents specified in the source.

    /// </summary>

    public abstract class SourceReferenceResolver

    {

        protected SourceReferenceResolver()

        {

        }



        public abstract override bool Equals(object other);

        public abstract override int GetHashCode();



        /// <summary>

        /// Normalizes specified source path with respect to base file path.

        /// </summary>

        /// 

        /// 

        /// <returns>Normalized path, or null if 

        /// 

        /// <returns>Normalized path, or null if the file can't be resolved.</returns>

        public abstract string ResolveReference(string path, string baseFilePath);



        /// <summary>

        /// Opens a <see cref="Stream"/> that allows reading the content of the specified file.

        /// </summary>

        /// 

        /// <exception cref="ArgumentNullException">

        public virtual SourceText ReadText(string resolvedPath)

        {

            using (var stream = OpenRead(resolvedPath))

            {

                return EncodedStringText.Create(stream);

            }

        }

    }

Now, for most use cases you’d only need to deal with SourceFileResolver, which is the default implementation, and simply allows you to look for the #loaded files in local files system.

Consider the following code:

//foo.csx file

Console.WriteLine("Hello from a file");



//main.csx file

#load "foo.csx"

Console.WriteLine("Hello from main file");

Now, the simplest possible program to execute these scripts (and look at diagnostics) is as follows:

public class Program

{

    public static void Main(string[] args)

    {

        var code = File.ReadAllText("main.csx");

        var opts = ScriptOptions.Default.

            AddImports("System");



        var script = CSharpScript.Create(code, opts);

        var compilation = script.GetCompilation();

        var diagnostics = compilation.GetDiagnostics();

        if (diagnostics.Any())

        {

            foreach (var diagnostic in diagnostics)

            {

                Console.WriteLine(diagnostic.GetMessage());

            }

        }

        else

        {

            var result = script.RunAsync().Result;

        }



        Console.ReadKey();

    }

}

If you try to execute these scripts with a Roslyn scripting engine, you will get the following error:

Source file 'foo.csx' could not be opened -- Could not find file.

When you set up the execution of your scripts, and you do not specify any resolver to be used, Roslyn will fallback to ScriptFileResolver - but the caveat is that by default the search paths are empty, and the base directory is set to null, so it you won’t be able to reference any files anyway.

So in order to run your code, you have to modify the ScriptOptions accordingly:

        var opts = ScriptOptions.Default.

            AddImports("System").

            WithSourceResolver(new SourceFileResolver(ImmutableArray<string>.Empty, AppContext.BaseDirectory));

In this case, we are in a .NET Core application, so we use AppContext.BaseDirectory to indicate about the current base directory. In a traditional .NET app, you’d probably look at AppDomain.CurrentDomain.BaseDirectory or wherever your #loaded files are located.

Now, this is fine, and if all you need is tweak how Roslyn is looking at the file system, instead of implementing the base abstract class directly, you can also choose to extend SourceFileResolver which might save you some keystrokes.

Creating a custom remote resolver πŸ”—

Let’s imagine the following use case - we’d like to simultaneously support:

  • #load from a local file, relative to my host’s (application running the script) application directory
  • #load from a remote file, over HTTP

At the moment Roslyn allows you to configure only one source resolver at a time, meaning if you want to be able to load from multiple source at once (say remote over HTTP and local file system), your resolver would have to do it all.

To do that, we’d have to implement a “hybrid”/“composite” resolver which will cater for both scenarios. Because of that, the extensibility point at the moment is not the most convenient. Another issue with it, is that it does assume all of the operations are synchronous, which is going to be a little constraining to us with network requests.

Nevertheless, the implementation of our custom RemoteFileResolver is shown below. The idea is to verify the type of path Roslyn will be requesting from us - and that is based on what the scripting engine finds in the #load directive - and yield processing the built in SourceFileResolver or use the logic that will be able to fetch the remote file.

The code is a bit long, but that’s mainly due to the fact that we have to implement GetHashCode and Equals.

public class RemoteFileResolver : SourceReferenceResolver

{

    private readonly Dictionary<string, string> _remoteFiles = new Dictionary<string, string>();

    private readonly SourceFileResolver _fileBasedResolver;



    public RemoteFileResolver() : this(ImmutableArray.Create(new string[0]),

            AppContext.BaseDirectory)

    {

    }



    public RemoteFileResolver(ImmutableArray<string> searchPaths, string baseDirectory)

    {

        _fileBasedResolver = new SourceFileResolver(searchPaths, baseDirectory);

    }



    public override string NormalizePath(string path, string baseFilePath)

    {

        var uri = GetUri(path);

        if (uri == null) return _fileBasedResolver.NormalizePath(path, baseFilePath);



        return path;

    }



    public override Stream OpenRead(string resolvedPath)

    {

        var uri = GetUri(resolvedPath);

        if (uri == null) return _fileBasedResolver.OpenRead(resolvedPath);



        if (_remoteFiles.ContainsKey(resolvedPath))

        {

            var storedFile = _remoteFiles[resolvedPath];

            return new MemoryStream(Encoding.UTF8.GetBytes(storedFile));

        }



        return Stream.Null;

    }



    public override string ResolveReference(string path, string baseFilePath)

    {

        var uri = GetUri(path);

        if (uri == null) return _fileBasedResolver.ResolveReference(path, baseFilePath);



        var client = new HttpClient();

        var response = client.GetAsync(path).Result;



        if (response.IsSuccessStatusCode)

        {

            var responseFile = response.Content.ReadAsStringAsync().Result;

            if (!string.IsNullOrWhiteSpace(responseFile))

            {

                _remoteFiles.Add(path, responseFile);

            }

        }

        return path;

    }



    private static Uri GetUrl(string input)

    {

        Uri uriResult;

        if (Uri.TryCreate(input, UriKind.Absolute, out uriResult)

                      && (uriResult.Scheme == "http"

                          || uriResult.Scheme == "https"))

        {

            return uriResult;

        }



        return null;

    }



    protected bool Equals(RemoteFileResolver other)

    {

        return Equals(_remoteFiles, other._remoteFiles) && Equals(_fileBasedResolver, other._fileBasedResolver);

    }



    public override bool Equals(object obj)

    {

        if (ReferenceEquals(null, obj)) return false;

        if (ReferenceEquals(this, obj)) return true;

        if (obj.GetType() != this.GetType()) return false;

        return Equals((RemoteFileResolver)obj);

    }



    public override int GetHashCode()

    {

        unchecked

        {

            var hashCode = 37;

            hashCode = (hashCode * 397) ^ (_remoteFiles?.GetHashCode() ?? 0);

            hashCode = (hashCode * 397) ^ (_fileBasedResolver?.GetHashCode() ?? 0);

            return hashCode;

        }

    }

}

So in short, if we can determine a valid http or https based URL, we will try to fetch from it remotely using HttpClient. Then we can store the result for later use in a local dictionary, so that later on, when Roslyn asks us for the Stream representing a given “file” (since our file is not physically a local file anymore, but an in memory representation of a remote file) we can just expose that saved string as a Stream.

This works reasonably well, so we can now update our script to reference some remote code. I have created a gist with some silly code here. It will simply spit out a hello from a remote file.

Now, our script can look like this:

    //foo.csx file

    Console.WriteLine("Hello from a file");



    //main.csx file

    #load "https://gist.githubusercontent.com/filipw/9a79bb00e4905dfb1f48757a3ff12314/raw/adbfe5fade49c1b35e871c49491e17e6675dd43c/foo.csx"

    #load "foo.csx"

    Console.WriteLine("Hello");

We should now plug in our custom resolver, everything else stays the same:

    var opts = ScriptOptions.Default.

       AddImports("System").

       WithSourceResolver(new RemoteFileResolver());

And if I execute our program, we get the expected output:

Screenshot 2016-06-17 09.35.21

Please note that all this sample code is built on .NET Core and compiled as netcoreapp1.0. You can find the full source code at Github.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP