It’s unlikely that you think of C# as a scripting language, but it can be used as one, as DevWeek speaker Dino Esposito explains.
By Dino Esposito
Published: 28 January 2005
It’s essential for developers and administrators to be able to automate tasks within the shell of the operating system. In the beginning, there were only MS-DOS batch files with their cryptic and compact syntax; they were ideal for die-hard, pure-blooded programmers, but not particularly suitable for the rest of us. After that, Microsoft introduced the Windows Script Host (WSH) environment, a COM-based runtime capable of processing VBScript and JScript files. WSH supports a good range of COM automation objects, and provides its own object model to manipulate the registry, the Windows shell, and the file system. It is not the perfect tool but, especially if you can create custom COM automation objects, it can be adapted to work in most common situations. WSH exists with different capabilities on all Windows operating systems, from Windows 98 on.
So what about .NET? A scripting environment is an OS feature, whereas the .NET Framework is an external framework that includes a class library as well as a virtual machine. However, by using some of the .NET Framework capabilities, you can implement a purely managed script host using C# (or Visual Basic .NET) as the programming language.
In this article, I’ll build a small executable capable of processing a C# snippet to a compiled assembly. The tool completes the user-defined source code with proper namespace and class declarations, adds an entry point, and calls it back using reflection. As a result, you can write a C# code snippet, save it to a file, and double-click to run it. The big advantage over WSH is that the whole .NET Framework is available to your scripts, along with the power of a first-class language such as C#.
Start simple
This example came about when, one day, I wanted to test the behaviour of a certain class. I had quite a few options for testing the class quickly: I could use a testing tool like NUnit, I could write a console application, a Web page or, for the sake of mentioning all my options, an overkill Windows Forms application.
Honestly, today I would use NUnit for the task; but this happened way back in the early days of .NET and I’m not even sure NUnit was available then or, at least, I didn’t know of it. This said, you know, every cloud has a silver lining and the batch compiler I’m presenting here is still an interesting tool with some good applications.
Basically, to test the class I didn’t want to create a project and wake up the sleeping giant (read, Visual Studio .NET). Wouldn’t it be great, I thought, if one could write C# code with the same ease as a WSH script? A few minutes later, I realised that the .NET Framework exposes the C# compiler as a class. Therefore, I deduced, writing a C# shell host for code snippets shouldn’t be that hard.
Suppose you, like me, are so absent-minded you need a program to remind you of important deadlines each morning as your computer starts up. This code does the job only too well:
DateTime deadline =
new DateTime(2005, 12, 31);
DateTime today = DateTime.Now;
TimeSpan left = deadline – today;
string BaseText = “{0} day(s) left
to meet deadline.”;
MessageBox.Show(String.Format(
BaseText, left.Days));
The hitch is that the code above won’t compile. It correctly represents and implements the logic of the applet, but it is not written in a form that the C# compiler can understand and transform into an executable. To be compiled, the code snippet must be completed as follows:
class CSharpScript {
static void Main() {
// C# snippet goes here
}
}
The name of the class is arbitrary and can optionally be completed with an arbitrary namespace. The C#Script program described in this article does just that. It takes a file that contains a C# snippet, completes the code so that it can be successfully compiled, compiles it, loads the resulting assembly, and invokes its entry point. Next, you assign a typical extension to these C# code snippets (for example, .csx) and register the C#Script program to handle them when they’re opened through the shell or a console window. The dream of double-clicking a C# snippet, and having it run, comes true at last. Let’s see how it happens.
Compile dynamically
Various components of the .NET platform make intensive use of dynamic compilation. The most illustrious example is probably the ASP.NET infrastructure. When you invoke an .aspx or .asmx resource for the first time, the ASP.NET runtime creates and compiles a class on the fly to represent the resource. The Web Service infrastructure also uses dynamically generated code to serialize data types to XML. The XmlSerializer class is in charge of this task and works by first creating a temporary assembly that contains optimized code for serialization.
Dynamic compilation is possible because some .NET compilers expose an object model that lets .NET applications call them programmatically. The C#, Visual Basic .NET, and JScript .NET compilers are included in the list. It is important to note that dynamic compilation doesn’t mean that you end up calling the compiler’s executable and configuring it through command line arguments. Instead, it means that you work with a .NET Framework class that implements the ICodeCompiler interface and incorporates the ability to talk to the real compiler tool. Ultimately, C# code is compiled by the csc.exe .NET compiler, but as a programmer you work with properties and methods of a compiler class.
To compile C# code programmatically, you start by importing the System.CodeDom.Compiler and Microsoft.CSharp namespaces. Both are defined within the System assembly so you don’t have to reference anything else. The key class is CSharpCodeProvider. It implements the ICodeCompiler interface and provides a self-explanatory method such as CreateCompiler.
CSharpCodeProvider prov = new CSharpCodeProvider();
ICodeCompiler compiler = prov.CreateCompiler();
At this point, you hold an in-memory instance of the object that represents the C# compiler (csc.exe). The next step is configuring the compiler. You set compiler parameters through an instance of the CompilerParameters class.
CompilerParameters cp = new CompilerParameters();
cp.GenerateExecutable = true;
cp.GenerateInMemory = true;
The GenerateExecutable property indicates whether you want a DLL or an executable. GenerateInMemory lets you indicate the storage medium for the resulting assembly. You can create it in memory or save to a particular file. Notice that a temporary file is created even if you set GenerateInMemory to true. Any assemblies that will be referenced during compilation must be added to the ReferencedAssemblies collection.
cp.ReferencedAssemblies.Add(“system.dll”);
cp.ReferencedAssemblies.Add(“system.xml.dll”);
cp.ReferencedAssemblies.Add(“system.data.dll”);
cp.ReferencedAssemblies.Add(“system.windows.forms.dll”);
Notice that you must indicate the .dll extension too. The ICodeCompiler interface provides several compile methods. Two in particular are worth mentioning: CompileAssemblyFromFile and CompileAssemblyFromSource. As the names suggest, the former compiles an existing C# source file. The latter, instead, compiles from a memory string. In this case, you must read the code snippet from the source file, complete it with class and entry point information, and finally compile from the string.
CompilerResults cr;
cr = compiler.CompileAssemblyFromSource(cp, finalSource);
The final source code is built using a string builder to wrap the code snippet with class and namespace declarations. Here’s the procedure that does the job:
int GenerateWithMain(StringBuilder sb,
string csSource, string nameSpace)
{
// Complete the source code to make it compilable
sb.AppendFormat(“\r\nnamespace {0}\r\n”, nameSpace);
sb.Append(“{\r\n”);
sb.Append(“class CSharpScript {\r\n”);
sb.Append(“static void Main() {\r\n”);
sb.Append(csSource);
sb.Append(“}\r\n}\r\n}\r\n”);
// Returns how many lines of text are added before
// actual CSX code is found
return 5;
}
The dynamically generated code includes a namespace based on the name of the source file. If the script lives in a file named test.csx, the corresponding namespace will be test_csx. What about the namespaces to import? The C#Script application adds a bunch of standard namespaces using clauses outside the class declaration. The list of standard imported namespaces is arbitrary. In this sample code I managed to include System.Data, System.Xml, System.IO, and System.Windows.Forms.
The code in the listing above ends with a rather funky return value. Why is it 5, you might be wondering? It has to do with error handling.
Handle Compiler Errors
The CompilerResults class is filled with information obtained from the compiler. It references the native compiler’s return value, any temporary files generated, output messages, and errors. Errors in particular are returned packed in a collection property named Errors. The C#Script executable grabs this information and prepares a custom error message. Here’s an example:
if (cr.Errors.HasErrors) {
StringBuilder sbErr;
sbErr = new StringBuilder(
“Compiling file: “);
sbErr.AppendFormat(
“\”{0}\””, csfile);
sbErr.Append(“\n\n”);
foreach(CompilerError
err in cr.Errors) {
sbErr.AppendFormat(“{0} at
line {1} column {2} “,
err.ErrorText, err.Line,
err.Column);
sbErr.Append(“\n”);
}
MessageBox.Show(sbErr.ToString(),
“C#Script – Error”);
return;
}
Each error is represented with a CompilerError object. It exposes useful information such as the line and the column where the error occurred and the message describing the error. Note that the Line property refers to the line in the final file – the one that it is being compiled to. This information is not precise because the user doesn’t know anything about the script’s internal structure. To be really helpful, the line number must refer to the line in the source CSX file. You must subtract some lines from the value returned by the Line property. In particular, you must subtract one line for each using clause you added and one for each line of text used to complete the source. The 5 returned by the GenerateWithMain procedure indicates that five new rows have been inserted before the source C# snippet to compile is found in the final code. I’ve included a message box that signals a syntax error (see Figure 1). The line number matches the line in the CSX snippet file perfectly.
Figure 1: When a compile error is caught, a message with a full description and line and column information pops up
Figure 1: When a compile error is caught, a message with a full description and line and column information pops up
Invoke the entry point
If the compiler succeeds, the compiled assembly is automatically loaded in memory and returned through the CompiledAssembly property of the CompilerResults class. The next step entails creating an instance of the class in the assembly and invoking the entry point. By design, the assembly contains just one class with a hard coded name – CSharpScript – and a static method Main().
Using reflection, you can obtain method information about the entry point and invoke it with or without parameters. This code demonstrates how invoke a parameterless Main method:
Assembly a = cr.CompiledAssembly;
try {
object o = a.CreateInstance(
“CSharpScript”);
MethodInfo mi = a.EntryPoint;
mi.Invoke(o, null);
}
catch(Exception ex) {
MessageBox.Show(ex.Message);
}
Any error that occurs within the method is trapped and a message box is displayed. Notice that the try/catch block catches only reflection errors so the final message doesn’t specify the error that occurred. To get more specific information, you should use a try/catch block directly in your CSX code. Alternatively, you can add a try/catch block in your GenerateWithMain() method.
Integrate it with the shell
So far I’ve built a small and compact executable – c#script.exe – that takes a file name on the command line that contains a C# code snippet. The program completes the source code to make it compile and loads the resulting assembly. Because the original goal was providing a .NET replacement for the WSH environment, you’ll need to take one more step to integrate the tool with the Windows shell. When you double-click on a file within the shell, Explorer looks for an application that is registered to open files of that type.
If such an application exists, it is invoked passing the file name on the command line. This schema is supplied for a variety of files, including VBS and JS files, but not CSX files. Registering an association between an executable and a file type requires tweaking the registry.
Take a look at a REG file that binds c#script.exe to CSX files and also registers a default icon for them:
Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\.csx]
@=”csxfile”
[HKEY_CLASSES_ROOT\csxfile]
@=”C# Script”
[HKEY_CLASSES_ROOT\csxfile\DefaultIcon]
@=”youricon.ico,0”
[HKEY_CLASSES_ROOT\csxfile\Shell]
[HKEY_CLASSES_ROOT\csxfile\Shell\Edit]
[HKEY_CLASSES_ROOT\csxfile\Shell\Edit Command]
@=”notepad.exe ‘%1’”
[HKEY_CLASSES_ROOT\csxfile\Shell\Open]
[HKEY_CLASSES_ROOT\csxfile\Shell\Open Command]
@=”\”c:\\C#Pro\\C#Script\ c#script.exe\” \”%1\” %*”
The REG file is a registry script that creates new keys and entries as specified. In particular, it creates a node named .csx under HKEY_CLASSES_ROOT (HKCR). The node notifies the system that a new file extension now exists. The default value of the key is set to csxfile, which is simply a reference to another node that contains actual information to handle the file type.
A new csxfile node is then always created under HKCR. This node contains the default icon to embellish CSX files in the shell and the commands to execute when a CSX file is opened or edited. This is what the csxfile node in the system registry looks like (see Figure 2).
Figure 2: All the information that enables the shell to handle CSX files is stored in the HKCR\csxfile node of the registry
Figure 2: All the information that enables the shell to handle CSX files is stored in the HKCR\csxfile node of the registry
It goes without saying that the icon and the command paths in the listing above are totally arbitrary. Make sure you modify the REG file to reflect the real path where you install your copy of c#script.exe.
Make further enhancements
What I’ve described so far is a basic version of the C#Script application. As I began using it, a number of enhancements sprang to mind. So far, C#Script references only a fixed number of assemblies and also imports a static set of namespaces. This means that what you can really do with it is limited in at least two ways. First, you can’t use any custom assemblies or any system assembly that is not automatically referenced. Second, you can’t address any .NET Framework functionality that requires a namespace that’s not included in the list, or at least that has the containing assemblies linked. For example, you can’t use the StringBuilder object because it requires the System.Text namespace. However, the StringBuilder class is defined in the mscorlib assembly, which is linked to the application. Thus if you used the fully qualified name of the class – System.Text.StringBuilder – it would work. Likewise, you cannot use network functions unless you figure out a way to import the System.Net namespace and the related assembly.
A possible workaround for any needed namespaces would be inserting required using clauses in the source or perhaps through a custom syntax like the one below.
<%@ Import Namespace=”...” %>
This is easier said than done given C#Script doesn’t parse the CSX source. In this version it is limited to flushing the code snippet into a string builder where other syntax elements are added to make the final code compile. Furthermore, even if you have C#Script detect and handle any line that begins with using, that will be of no help for referencing extra assemblies.
To make improvements regarding this key point, you can either define your own syntax and add a parser or associate a companion file to the snippet. My solution is based on additional XML files that you can use to create a “project” around the code snippet. C#Script assigns a special meaning to any XML file found in the same folder as the snippet whose name begins with the name of the CSX file. For example, test.csx.xml is the project file for test.csx. This listing shows the typical contents of the project file:
The XML schema fits perfectly to a DataSet with two tables: Reference and Import. Both tables have a single column called Name. The code below is an excerpt from C#Script that demonstrates how project information is retrieved and managed:
string nameSpace = Path.GetFileName(csfile);
nameSpace = nameSpace.Replace(“.”, “_”);
CSharpCodeProvider prov = new CSharpCodeProvider();
ICodeCompiler icc =
prov.CreateCompiler();
CompilerParameters cp =
new CompilerParameters();
cp.GenerateExecutable = true;
cp.GenerateInMemory = true;
// Try to read from the config file
bool hasConfig = false;
DataSet configData = new DataSet();
string configFile =
String.Format(“{0}.xml”, csfile);
if (File.Exists(configFile)) {
hasConfig = true;
configData.ReadXml(configFile);
}
// Add default references
cp.ReferencedAssemblies.Add(
“system.dll”);
cp.ReferencedAssemblies.Add(
“system.xml.dll”);
cp.ReferencedAssemblies.Add(
“system.data.dll”);
cp.ReferencedAssemblies.Add(
“system.windows.forms.dll”);
// Add user-defined references
if (hasConfig)
{
DataTable refs =
configData.Tables[“Reference”];
foreach(DataRow row in refs.Rows) {
cp.ReferencedAssemblies.Add(
row[“Name”].ToString());
}
}
int usingLines = 7;
// going to add 7 lines
StringBuilder sb =
new StringBuilder(“”);
sb.Append(“using System;\r\n”);
sb.Append(“using
System.Windows.Forms;\r\n”);
sb.Append(“using System.IO;\r\n”);
sb.Append(“using System.Xml;\r\n”);
sb.Append(“using System.Data;\r\n”);
sb.Append(“using
System.Data.SqlClient;\r\n”);
sb.Append(“using
System.Data.SqlTypes;\r\n”);
// Add user-defined Imports
if (hasConfig)
{
DataTable imports =
configData.Tables[“Import”];
foreach(DataRow row in
imports.Rows) {
sb.AppendFormat(
“using {0};\r\n”,
row[“Name”].ToString());
usingLines++;
}
}
// Get the source code from the
// CSX file
StreamReader sr =
new StreamReader(csfile);
string source = sr.ReadToEnd();
sr.Close();
// Get the final source code
int topLines = GenerateWithMain(
sb, source, nameSpace);
string finalSourceCode = sb.ToString();
// Ready to compile now
If you use a custom assembly, then you must place it in the same folder where you install the c#script utility.
Supporting custom methods
At this point, you can reference any assembly and make calls into any namespace. However, you can’t create methods yet and are forced to write your snippet as a single piece of code. There are two ways to eliminate this shortcoming. One is to add parsing capabilities to C#Script, extract any code that’s not included in a method or function and flush it in the C#Script’s Main entrypoint. Another approach, which is simpler to code but equally effective, makes some reasonable assumptions about the structure of the CSX file.
In particular, if custom functions are to be used, then the main code must be wrapped in a function too. This function must be named main(). Lowercase is important because Main() has a special meaning the C# compiler. The main() function is not the real entry point to the code; it is just a name that reminds you of its logical role. Even though the name is arbitrary, once you make your choice you must stick with it and hardcode it in the C#Script source.
When completing the code snippet for compilation, C#Script looks for a void main() substring. If it’s found, C#Script assumes that you want to define your classes and methods. As a result, the code completion follows a different scheme:
namespace test_csx
{
public class CSharpScript()
{
public static void Main() {
CSharpScript app =
new CSharpScript();
}
public CSharpScript() {
main();
{
// C# snippet goes here that
// contains main()
}
}
To round off the article, let’s write some sample code to demonstrate how C#Script really works.
One of the first things I did when learned about Amazon.com was to write a small application to download information about books. I arranged a class that makes a call to the Web service and serves information back to the caller using a DataTable. The full script is:
TextBox txt;
void main()
{
string isbn = “0735619034”;
Form f = new Form();
f.Text = “Amazon Book Finder”;
txt = new TextBox();
txt.Text = isbn;
Button btn = new Button();
btn.Left = 130;
btn.Text = “Find”;
btn.Click += new
EventHandler(OnFind);
f.Controls.Add(btn);
f.Controls.Add(txt);
f.ShowDialog();
}
void OnFind(object sender,
EventArgs e)
{
AmazonBooksInfo info =
new AmazonBooksInfo();
DataTable details =
info.FindBook(txt.Text);
ShowData(details, txt.Text);
}
void ShowData(DataTable dt,
string key)
{
StringBuilder sb = new
StringBuilder(“”);
foreach(DataRow row in dt.Rows)
{
sb.AppendFormat(“{0} ({1}) --
{2} sales rank\n”,
row[“ProductName”].
ToString(), row[
“Manufacturer”].ToString(),
row[“SalesRank”].
ToString());
}
MessageBox.Show(sb.ToString(),
“Results for \”” + key + “\””);
}
It creates a form and populates it with a textbox and a button. The button is bound to an event handler that fires when a user clicks the button. The handler uses a custom class in a custom assembly to connect to Amazon.com and grab the requested information (see Figure 3).
Figure 3: The results generated by running the BookInfo.csx file against a book ISBN
Figure 3: The results generated by running the BookInfo.csx file against a book ISBN
Thanks to the CodeDOM facilities in the .NET Framework, you can compile code on the fly. And thanks to .NET reflection, you can load an assembly dynamically, instantiate its classes, and call methods. By combining these features, I’ve built a C# snippet processor. It is a small (8KB) executable that completes the C# snippet stored in a CSX file with class and namespace declarations and runs it.
What’s the deal? You can now use C# as a language for writing batch files and automating tasks. No explicit compilation step is required and no project files are needed. Just write, click, and go. It’s pure C#, pure .NET.
No comments:
Post a Comment