Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

CommScript

4.42/5 (7 votes)
3 Apr 2010CPOL12 min read 1   207  
A simple scripting engine for automating communication (e.g. Telnet)

Introduction

This article deals with my CommScript class, which is a simple scripting engine for automating communication (e.g., Telnet).

What is presented here is the result of several year's evolution; originally, the socket and the script engine were combined in one class, but I have since decided that the engine could be used to execute scripts on other types of communication links, so I split them apart.

Background

One of the more irksome, yet challenging and ultimately rewarding things I had to accomplish on my last job was the automation of Telnetting into a Unix system, running a third-party application, navigating a character-based menu/form system, entering data, and responding to feedback -- and still exiting neatly. It quickly became apparent that I needed to have my code write a script and then have something execute the script. For various reasons, I decided to write my own script language and interpreter.

The primary concern in my design and implementation of this class was the ability to have it automate interaction with a third-party application on a Unix system. The scripts were generated and executed within a Windows Service -- there was no need for a user interface, so no concern was given to being usable by interactive processes. Likewise, although the scripts may be stored in files then read and passed in, the primary purpose was to generate the scripts directly in strings on the fly.

IScriptableCommunicator

The CommScript class requires an instance of a class that implements the IScriptableCommunicator interface:

C#
public delegate void DataReceived ( string Data ) ;
 
public delegate void ExceptionCaught ( System.Exception Exception ) ;
 
public interface IScriptableCommunicator : System.IDisposable
{
    void Connect   ( string Host ) ;
    void WriteLine ( string Data , params object[] Parameters ) ;
    void Write     ( string Data , params object[] Parameters ) ;
    void Close     () ;
 
    System.TimeSpan ResponseTimeout { get ; set ; }
 
    System.Text.Encoding Encoding { get ; set ; }
 
    event DataReceived    OnDataReceived    ;
    event ExceptionCaught OnExceptionCaught ;
}

With an instance of a class that implements this interface, your code can:

  • Set a timeout (the connection will abort if the timeout expires)
  • Specify an encoding to use to convert between byte[] and string
  • Connect to a Telnet server
  • Send data to the server
  • Receive data back
  • Receive Exceptions that occur when sending data

The Scripting Language

The (unnamed) scripting language I present here has evolved from a very simple scripting language I encountered many years ago. At that time, one of my co-workers was configuring terminal servers with a utility called "fta" (as I recall); I have no idea where it originated, I have found no information on it, I assume it came from Unix, though we were using OpenVMS. The utility executed scripts which had two types of lines, which could be thought of as "send" and "wait"; "wait" commands began with a period (.), "send" lines were everything else. The crazy part was that the "wait" had to be before the "send" command, but would remain in effect until another "wait" was encountered.

When I determined that I needed to create and implement my own scripting language, I thought of those fta scripts as a reasonable starting point. I recognized that being line-oriented and having the "wait" precede the "send", though clumsy, allowed for very simple parsing and execution. But I knew I needed more than two types of commands, that meant that I needed an actual "send" command. After perusing my old copy of Principles of Programming Languages[^] (second edition, 1987), I determined that what fta lacked is "regularity" (don't we all?) and "consistency". However, I decided to stick with being line-oriented with single-character symbolic commands (hey! it's a non-English-based computer language!). OK, you can stop reading if that makes you queasy ("It scares the willies out of me." -- Slartibartfast)

Therefore, I needed to find single-character symbols for "send" and "wait"; they needed to be similar to each other, hint at their purpose, and be memorable. I chose ">" (greater than) for Send, and "<" (less than) for Wait (replacing period). For a Connect command, "@" (at) seems appropriate. Comments can be important; in the past, I've used ";" (semi-colon) for that, and I did so again. I needed an exit command, so I chose "$" (dollar sign) to indicate a successful completion of the script (the money shot).

Those were enough to get started, but I soon learned that I needed some amount of flexibility for dealing with exceptions. Therefore, I added subroutines, and a sort of handler or "on error gosub" or "if you see this, call that" functionality. The Branch command -- "?" (question mark) -- maps a handler for a situation. You can also Call a subroutine directly with the "^" (carat) command. There are two types of subroutines: []-type subroutines and {}-type subroutines, and I can never remember which is which. The difference is that with []-type subroutines, any "wait" and "on error gosub" settings remain in effect after the subroutine returns; with {}-type subroutines, the "wait" and "on error gosub" settings that were in effect before the subroutine was called will be restored when the subroutine returns. (Terminating a script from within a subroutine is allowed.)

When interacting with the third-party character-based menu/form application, there were times when "send" (which adds a carriage-return) wasn't the right tool. So, I added the Press command -- "#" (number , pound) -- which is designed to send keystrokes (with no carriage-return). The most common use is to define a keystroke and then send it with the Press (#) command. The Define command -- "%" (percent) -- is used to define a "known string" (typically a keystroke or other short sequence). Known strings may also be provided to the CommScript constructor, this allows sharing the strings among several instances of CommScript.

As my scripts became more complex, I realized that the Success ($) command was not flexible enough; it returns only zero (0) for success; I needed to return other values that might mean partial success, or at least not complete failure. The result is the Error command -- "!" (exclamation point) -- which pretty much makes the Success ($) command obsolete.

Miscellaneous commands:

  • It also seemed like including code stored in other files might be a good idea, so I added the Include command -- "&" (ampersand).
  • And, the ability to store (log) output in a file can be useful in some cases, so I added the Log command -- "|" (vertical bar, pipe) -- as well.

Many of the commands also make use of the "=" (equal sign) to separate operands of the command. The telnets.html file included in the zip has more information on the script language.

Still with me?

I'm sure that lots of readers would have already skipped to the bottom to 1-vote the language; thanks for staying. However, as I said, the language has evolved. Because I now use a Regular Expression and an EnumTransmogrifier[^] to split and parse the command, you can now use command names (not case-sensitive) rather than the symbols, or mix them together. If the command has operands, there must be a whitespace character after the command name.

Operation

The Operation enumeration contains the names and symbols for the commands.

C#
[PIEBALD.Attributes.EnumDefaultValue((int)Operation.Noop)]
public enum ScriptCommand
{
    [System.ComponentModel.DescriptionAttribute("")]
    Noop
,
    [System.ComponentModel.DescriptionAttribute(";")]
    Comment
,
    [System.ComponentModel.DescriptionAttribute("%")]
    Define
,
    [System.ComponentModel.DescriptionAttribute("&")]
    Include
,
    [System.ComponentModel.DescriptionAttribute("[")]
    StartBracket
,
    [System.ComponentModel.DescriptionAttribute("]")]
    EndBracket
,
    [System.ComponentModel.DescriptionAttribute("{")]
    StartBrace
,
    [System.ComponentModel.DescriptionAttribute("}")]
    EndBrace
,
    [System.ComponentModel.DescriptionAttribute("^")]
    Call
,
    [System.ComponentModel.DescriptionAttribute("|")]
    Log
,
    [System.ComponentModel.DescriptionAttribute("@")]
    Connect
,
    [System.ComponentModel.DescriptionAttribute("?")]
    Branch
,
    [System.ComponentModel.DescriptionAttribute("<")]
    Wait
,
    [System.ComponentModel.DescriptionAttribute(">")]
    Send
,
    [System.ComponentModel.DescriptionAttribute("#")]
    Press
,
    [System.ComponentModel.DescriptionAttribute("!")]
    Error
,
    [System.ComponentModel.DescriptionAttribute("$")]
    Success
}

Statement

The Statement class holds an operation and its operands. Some of you may recommend having a sub-class of Statement for each type of command, but I don't think anything would be gained by that.

C#
private class Statement
{
    public readonly Operation Command  ;
    public readonly string    Operand1 ;
    public readonly string    Operand2 ;
    
    private Statement
    (
        Operation Command
    ,
        string    Operand1
    ,
        string    Operand2
    )
    {
        this.Command  = Command  ;
        this.Operand1 = Operand1 ;
        this.Operand2 = Operand2 ;
        
        return ;
    }
    
    public override string
    ToString
    (
    )
    {
        string result = System.String.Format
        (
            "{0}{1}{2}"
        ,
            command.ToString ( this.Command )
        ,
            this.Operand1 == null ? System.String.Empty : this.Operand1
        ,    
            this.Operand2 == null ? System.String.Empty : "=" + this.Operand2
        ) ;
        
        return ( result ) ;
    }
}

Parse

The Parse method splits and parses a line, then instantiates a Statement. Blank lines result in Noops. If a line can't be parsed successfully, an Exception will be thrown.

The Regular Expression and the enumeration must be kept in sync.

C#
private static readonly System.Text.RegularExpressions.Regex          reg      ;
private static readonly PIEBALD.Types.EnumTransmogrifier<Operation>   command  ;
private static readonly System.Collections.Generic.HashSet<Operation> binaryop ;
 
public static readonly Statement Noop ;
    
static Statement
(
)
{
    reg = new System.Text.RegularExpressions.Regex
    (
        "^\\s*(?:(?'Command'[;<>[\\]{}$^!&%|?#@])|(?:(?'Command'\\w+)\\s?))" +
        "(?'Parameter'(?'Name'[^=]*)?(?:=(?'Value'.*))?)?$"
    ) ;
    
    command = new EnumTransmogrifier<Operation>
    (
        System.StringComparer.CurrentCultureIgnoreCase
    ) ;
        
    binaryop = new System.Collections.Generic.HashSet<Operation>() ;
        
    binaryop.Add ( Operation.Branch       ) ;
    binaryop.Add ( Operation.Define       ) ;
    binaryop.Add ( Operation.Error        ) ;
    binaryop.Add ( Operation.StartBrace   ) ;
    binaryop.Add ( Operation.StartBracket ) ;
        
    Noop = new Statement ( Operation.Noop , null , null ) ;
        
    return ;
}
    
public static Statement
Parse
(
    string Line
)
{
    Statement result = Noop ;
    
    System.Text.RegularExpressions.MatchCollection mat = reg.Matches ( Line ) ;
    
    if ( mat.Count == 1 )
    {
        /* This will throw System.ArgumentException if a valid command is not found */
        Operation cmd = command.Parse ( mat [ 0 ].Groups [ "Command" ].Value ) ;
 
        if ( binaryop.Contains ( cmd ) )
        {
            result = new Statement 
            ( 
                cmd 
            , 
                mat [ 0 ].Groups [ "Name" ].Value
            , 
                mat [ 0 ].Groups [ "Value" ].Value
            ) ;
        }
        else
        {
            result = new Statement 
            ( 
                cmd 
            , 
                mat [ 0 ].Groups [ "Parameter" ].Value
            ,
                null
            ) ;
        }
    }
    
    return ( result ) ;
}

Subroutine

The Subroutine class holds the name, type, starting line number, and ending line number of a subroutine. The only way to return from a subroutine is by reaching the ending line number.

C#
private class Subroutine
{
    public enum SubroutineType
    {
        Bracket
    ,
        Brace
    }
 
    public readonly string         Name  ;
    public readonly SubroutineType Type  ;
    public readonly int            Start ;
 
    /* End can't be readonly, it must be set (once) after construction */
    private int                    end   ;
 
    public Subroutine
    (
        string         Name
    ,
        SubroutineType Type
    ,
        int            Start
    )
    {
        this.Name  = Name  ;
        this.Type  = Type  ;
        this.Start = Start ;
        this.end   = 0     ;
 
        return ;
    }
 
    /* End can be set to a non-zero value only once */
    public int
    End
    {
        get
        {
            return ( this.end ) ;
        }
 
        set
        {
            if ( this.end != 0 )
            {
                throw ( new System.InvalidOperationException
                    ( "Attempt to reset the End of a Subroutine" ) ) ;
            }
 
            this.end = value ;
 
            return ;
        }
    }
 
    public override string
    ToString
    (
    )
    {
        return ( this.Name ) ;
    }
}

SubroutineCall

The SubroutineCall class holds a reference to a Subroutine and the state of the engine when it was called. When a subroutine returns, the program counter must be restored. The states of the sought and branches will also be restored if the subroutine is a Bracket-type subroutine.

C#
private class SubroutineCall
{
    public readonly Subroutine                                           Subroutine ;
 
    public readonly int                                                  From       ;
    public readonly string                                               Sought     ;
    public readonly System.Collections.Generic.Dictionary<string,string> Branches   ;
 
    public SubroutineCall
    (
        Subroutine                                           Subroutine
    ,
        int                                                  From
    ,
        string                                               Sought
    ,
        System.Collections.Generic.Dictionary<string,string> Branches
    )
    {
        this.Subroutine = Subroutine ;
 
        this.From       = From       ;
        this.Sought     = Sought     ;
                
        /* Clone the branches collection */
        this.Branches   = new System.Collections.Generic.Dictionary<string,string> 
            ( 
                Branches 
            , 
                Branches.Comparer 
            ) ;
 
        return ;
    }
 
    public override string
    ToString
    (
    )
    {
        return ( this.Subroutine.ToString() ) ;
    }
}

CommScript

Which brings us to the CommScript class. A class that implements IScriptableCommunicator must be passed to the constructor. A collection of known strings may also be passed, this allows an application to define the known strings ahead of time; I used this feature as a performance boost when I used this in production.

The other fields are for holding the script, a Dictionary of Subroutines, and the state of the engine.

C#
public sealed class CommScript : System.IDisposable
{
    private PIEBALD.Types.IScriptableCommunicator                    socket             ;
    private System.Collections.Generic.Dictionary<string,string>     knownstrings ;
 
    private System.Collections.Generic.List<Statement>               script      = null ;
    private System.Collections.Generic.Dictionary<string,Subroutine> subroutines = null ;
    private System.Collections.Generic.Dictionary<string,string>     branches    = null ;
    private System.Collections.Generic.Stack<SubroutineCall>         substack    = null ;
    private System.Collections.Generic.Stack<System.IO.TextWriter>   outstream   = null ;
    private System.Text.StringBuilder                                response     = null ;
    
    private int                                                      pc           = 0    ; 
    private string                                                   sought       = ""   ;
    private bool                                                     found        = true ;
 
    public CommScript
    (
        PIEBALD.Types.IScriptableCommunicator Socket
    )
    : this
    (
        Socket
    ,
        null
    )
    {
        return ;
    }
 
    public CommScript
    (
        PIEBALD.Types.IScriptableCommunicator                Socket
    ,
        System.Collections.Generic.Dictionary<string,string> KnownStrings
    )
    {
        if ( Socket == null )
        {
            throw ( new System.ArgumentNullException ( "Socket" , 
                        "Socket must not be null" ) ) ;
        }
 
        this.socket = Socket ;
 
        this.socket.OnDataReceived    += this.ProcessResponse      ;
        this.socket.OnExceptionCaught += this.RaiseExceptionCaught ;
 
        if ( KnownStrings == null )
        {
            this.knownstrings = 
              new System.Collections.Generic.Dictionary<string,string>() ;
        }
        else
        {
            this.knownstrings = KnownStrings ;
        }
 
        return ;
    }
 
    public void
    Dispose
    (
    )
    {
        if ( this.socket != null )
        {
            this.socket.OnDataReceived    -= this.ProcessResponse      ;
            this.socket.OnExceptionCaught -= this.RaiseExceptionCaught ;
 
            this.socket.Dispose() ;
        }
 
        this.knownstrings = null ;
 
        this.response     = null ;
        this.branches     = null ;
        this.script       = null ;
        this.subroutines  = null ;
        this.substack     = null ;
        this.outstream    = null ;
 
        return ;
    }
}

ExecuteScript

Once instantiated, the only thing you can do with a CommScript is execute a script. The primary way I use a CommScript is to cobble up the script in a string and pass it in. You could also execute a script that is stored in a file or piped into a utility. The data from the host will be returned in the Outstream parameter. The return code is determined by the script engine:

  • If a $ statement is executed, the returned value is zero (0).
  • If a ! statement is executed, the returned value is the line number or the value provided by the ! statement.
  • If an error occurs in the script, the returned value will be the negative of the line number that encountered the error.
C#
public int
ExecuteScript
(
    string     Script
,
    out string Outstream
)
{
    if ( Script == null )
    {
        throw ( new System.ArgumentNullException ( "Script" , 
                           "Script must not be null" ) ) ;
    }
 
    System.IO.StringWriter temp = new System.IO.StringWriter() ;
 
    try
    {
        temp.NewLine = "\n" ;
 
        return ( this.DoExecuteScript ( 
                   new System.IO.StringReader ( Script ) , temp ) ) ;
    }
    finally
    {
        Outstream = temp.ToString() ;
    }
}
 
public int
ExecuteScript
(
    System.IO.TextReader Script
,
    System.IO.TextWriter Outstream
)
{
    if ( Script == null )
    {
        throw ( new System.ArgumentNullException ( "Script" , 
                    "Script must not be null" ) ) ;
    }
 
    if ( Outstream == null )
    {
        throw ( new System.ArgumentNullException ( "Outstream" , 
                    "Outstream must not be null" ) ) ;
    }
 
    return ( this.DoExecuteScript ( Script , Outstream ) ) ;
}

DoExecuteScript

This method sets up the fields of the CommScript to hold the script, loads it, finds and validates any subroutines, and activates the engine.

C#
private int
DoExecuteScript
(
    System.IO.TextReader Script
,
    System.IO.TextWriter Outstr
)
{
    this.response    = new System.Text.StringBuilder()                                ;
    this.script      = new System.Collections.Generic.List<Statement>()               ;
    this.substack    = new System.Collections.Generic.Stack<SubroutineCall>()         ;
    this.branches    = new System.Collections.Generic.Dictionary<string,string>()     ;
    this.outstream   = new System.Collections.Generic.Stack<System.IO.TextWriter>()   ;
    this.subroutines = new System.Collections.Generic.Dictionary<string,Subroutine>() ;
 
    this.outstream.Push ( Outstr ) ;
 
    this.LoadScript ( Script ) ;
 
    this.MapSubroutines() ;
 
    this.ValidateSubroutineCalls() ;
 
    return ( this.DoExecuteScript() ) ;
}

LoadScript

Obviously, LoadScript loads the script into memory. Each line is parsed into a Statement instance, then there is special handling of some statements:

  • Includes will cause the specified file to be read-in in place of the statement
  • Subroutines may also indicate that a file should be read-in
  • Defines are checked to be sure that there's at least a name
  • All other lines are added to the script as they are
C#
private void
LoadScript
(
    System.IO.TextReader Script
)
{
    string    line      ;
    Statement statement ;
 
    while ( ( Script.Peek() != -1 ) && ( ( line = Script.ReadLine() ) != null ) )
    {
        statement = Statement.Parse ( line ) ;
 
        switch ( statement.Command )
        {
            case Statement.Operation.Include :
            {
                this.IncludeFile ( statement.Operand1 ) ;
 
                break ;
            }
 
            case Statement.Operation.StartBracket :
            {
                if ( statement.Operand1.Length == 0 )
                {
                    throw ( new System.InvalidOperationException ( 
                                       "No name for subroutine" ) ) ;
                }
 
                this.script.Add ( statement ) ;
 
                if ( statement.Operand2.Length > 0 )
                {
                    this.IncludeFile ( statement.Operand2 ) ;
 
                    this.script.Add ( Statement.Parse ( "]" ) ) ;
                }
 
                break ;
            }
 
            case Statement.Operation.StartBrace :
            {
                if ( statement.Operand1.Length == 0 )
                {
                    throw ( new System.InvalidOperationException ( 
                                "No name for subroutine" ) ) ;
                }
 
                this.script.Add ( statement ) ;
 
                if ( statement.Operand2.Length > 0 )
                {
                    this.IncludeFile ( statement.Operand2 ) ;
 
                    this.script.Add ( Statement.Parse ( "}" ) ) ;
                }
 
                break ;
            }
     
            case Statement.Operation.Define :
            {
                if ( statement.Operand1.Length == 0 )
                {
                    throw ( new System.InvalidOperationException ( 
                                "No name for known string" ) ) ;
                }
 
                this.script.Add ( statement ) ;
 
                break ;
            }
 
            default :
            {
                this.script.Add ( statement ) ;
 
                break ;
            }
        }
    }
 
    return ;
}

IncludeFile

Inclusion of files happens recursively -- so be careful.

C#
private void
IncludeFile
(
    string FileName
)
{
    System.IO.FileInfo fi = PIEBALD.Lib.LibFil.GetExpandedFileInfo ( FileName ) ;
 
    if ( !fi.Exists )
    {
        throw ( new System.ArgumentException
        (
            string.Format ( "File {0} does not exist" , FileName )
        ) ) ;
    }
 
    this.LoadScript ( fi.OpenText() ) ;
 
    return ;
}

MapSubroutines

Any subroutine in the script must be added to the Subroutines Dictionary. This method iterates the statements of the script, seeking bracket and brace statements. When a subroutine start is detected, a Subroutine instance is created for it and pushed onto the stack. When a subroutine end is detected, a Subroutine instance is popped off the stack, validated, and added to the Subroutines Dictionary.

C#
private void
MapSubroutines
(
)
{
    Statement  statement ;
    Subroutine temp      ;
 
    int        linenum   = 0 ;
 
    System.Collections.Generic.Stack<Subroutine> stack =
        new System.Collections.Generic.Stack<Subroutine>() ;
 
    while ( linenum < script.Count )
    {
        statement = this.script [ linenum++ ] ;
 
        switch ( statement.Command )
        {
            case Statement.Operation.StartBracket :
            {
                stack.Push ( new Subroutine
                (
                    statement.Operand1
                ,
                    Subroutine.SubroutineType.Bracket
                ,
                    linenum
                ) ) ;
 
                break ;
            }
 
            case Statement.Operation.EndBracket :
            {
                if ( stack.Count == 0 )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: 
			Extraneous subroutine terminator" , linenum )
                    ) ) ;
                }
 
                temp = stack.Pop() ;
 
                if ( temp.Type != Subroutine.SubroutineType.Bracket )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: 
			Mismatched subroutine delimiters" , linenum )
                    ) ) ;
                }
 
                if ( this.subroutines.ContainsKey ( temp.Name ) )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: Duplicate subroutine name" , linenum )
                    ) ) ;
                }
 
                temp.End = linenum ;
 
                this.subroutines [ temp.Name ] = temp ;
 
                break ;
            }
 
            case Statement.Operation.StartBrace :
            {
                stack.Push ( new Subroutine
                (
                    statement.Operand1
                ,
                    Subroutine.SubroutineType.Brace
                ,
                    linenum
                ) ) ;
 
                break ;
            }
 
            case Statement.Operation.EndBrace :
            {
                if ( stack.Count == 0 )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: 
			Extraneous subroutine terminator" , linenum )
                    ) ) ;
                }
 
                temp = stack.Pop() ;
 
                if ( temp.Type != Subroutine.SubroutineType.Brace )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: 
			Mismatched subroutine delimiters" , linenum )
                    ) ) ;
                }
 
                if ( this.subroutines.ContainsKey ( temp.Name ) )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: Duplicate subroutine name" , linenum )
                    ) ) ;
                }
 
                temp.End = linenum ;
 
                this.subroutines [ temp.Name ] = temp ;
 
                break ;
            }
        }
    }
 
    if ( stack.Count > 0 )
    {
        throw ( new System.InvalidOperationException
        (
            "The script contains one or more unterminated subroutines"
        ) ) ;
    }
 
    return ;
}

ValidateSubroutineCalls

Once the subroutines are in the Dictionary, the calls and branches to them can be validated. A subroutine call must specify a valid name. If a branch (? statement) specifies a name, it must be a valid name.

C#
private void
ValidateSubroutineCalls
(
)
{
    Statement statement ;
 
    int       linenum   = 0 ;
 
    while ( linenum < script.Count )
    {
        statement = this.script [ linenum++ ] ;
 
        switch ( statement.Command )
        {
            case Statement.Operation.Branch :
            {
                if ( statement.Operand1.Length > 0 )
                {
                    if ( !this.subroutines.ContainsKey ( statement.Operand1 ) )
                    {
                        throw ( new System.InvalidOperationException
                        (
                            string.Format
                            (
                                "{0}: No subroutine named {1} defined"
                            ,
                                linenum
                            ,
                                statement.Operand1
                            )
                        ) ) ;
                    }
                }
 
                break ;
            }
 
            case Statement.Operation.Call :
            {
                if ( statement.Operand1.Length == 0 )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format ( "{0}: No subroutine name specified" , linenum )
                    ) ) ;
                }
 
                if ( !this.subroutines.ContainsKey ( statement.Operand1 ) )
                {
                    throw ( new System.InvalidOperationException
                    (
                        string.Format
                        (
                            "{0}: No subroutine named {1} defined"
                        ,
                            linenum
                        ,
                            statement.Operand1
                        )
                    ) ) ;
                }
 
                break ;
            }
        }
    }
 
    return ;
}

CallSubroutine

This method effects a subroutine call; it may be called by a ^ (call) command in the script or by a branch. It stores the current program counter and the states of the sought and branches so they can be restored when the subroutine returns. Then, it sets the program counter to the first line of the subroutine.

C#
private void
CallSubroutine
(
    string Name
)
{
    this.substack.Push
    (
        new SubroutineCall
        (
            this.subroutines [ Name ]
        ,
            this.pc
        ,
            this.sought
        ,
            this.branches
        )
    ) ;
 
    this.pc = this.subroutines [ Name ].Start ;
 
    return ;
}

ProcessResponse

This is the handler for the communicator's OnDataReceived event. Its purpose is to scan the responses from the host for the text being sought and the various texts that will trigger a branch.

I'm using a string rather than a StringBuilder because StringBuilder doesn't have a built-in ability to search for a string; therefore I would have to use ToString after each Append, and that would probably counteract any benefit of using the StringBuilder. If you don't like it, change it.

When data is received, it is sent to the output stream, then appended to any data still in the buffer. The buffer is then scanned for the text that is being sought and each text that will trigger a branch. By getting the offset of any such text found, and keeping only the smallest such offset, we will know which text appears first in the current response. We can then call a subroutine if a branch was triggered, remove data from the buffer, and signal the engine that it can proceed.

The algorithm could probably be more pro-active in removing data from the buffer and thereby reduce the memory load.

C#
private void
ProcessResponse
(
    string Data
)
{
    try
    {
        string branch = System.String.Empty ;
        int    minoff = -1 ;
 
        lock ( this.outstream )
        {
            this.outstream.Peek().Write ( Data ) ;
        }
 
        this.response += Data ;
 
        if ( this.sought.Length > 0 )
        {
            minoff = this.response.IndexOf ( this.sought ) ;
        }
 
        foreach ( string temp in this.branches.Keys )
        {
            int offs = this.response.IndexOf ( temp ) ;
 
            if ( offs != -1 )
            {
                if ( ( minoff == -1 ) || ( minoff > offs ) )
                {
                    minoff = offs ;
                    branch = temp ;
                }
            }
        }
 
        if ( minoff != -1 )
        {
            if ( branch.Length > 0 )
            {
                this.CallSubroutine ( this.branches [ branch ] ) ;
 
                this.response = 
                  this.response.Substring ( minoff + branch.Length ) ;
            }
            else
            {
                this.response = 
                  this.response.Substring ( minoff + this.sought.Length ) ;
            }
 
            this.found = true ;
        }
    }
    catch ( System.Exception err )
    {
        this.RaiseExceptionCaught ( err ) ;
    }
 
    return ;
}

DoExecuteScript

Here, then, is the script engine. It is, of course, mostly a while loop with a switch. The while loop terminates after a $ or ! command is executed, or if the program counter (pc) becomes invalid (runs off the end of the script). The process will sleep and loop until ProcessResponse signals that any of the expected strings has appeared in the output from the host. When signaled to proceed, the engine gets the next Statement from the script and then the switch performs the required action. If an Exception is encountered, the program counter is set to its negative, the Exception is written out to the output stream, and the method returns. When the method returns, the program counter is used as the return code.

C#
private int
DoExecuteScript
(
)
{
    Statement statement = Statement.Noop ;
 
    this.pc     = 0    ;
    this.found  = true ;
    this.sought = System.String.Empty ;
 
    try
    {
        while
        (
            ( statement.Command != Statement.Operation.Success )
        &&
            ( statement.Command != Statement.Operation.Error )
        &&
            ( this.pc >= 0 )
        &&
            ( this.pc < this.script.Count )
        )
        {
            if ( found )
            {
                statement = script [ this.pc++ ] ; /* Note the post-increment */
 
                lock ( this.outstream )
                {
                    this.outstream.Peek().WriteLine ( statement.ToString() ) ;
                }
 
                switch ( statement.Command )
                {
                    /* On success; set the PC to zero                        */
                    /* If there is text to send, send it and wait one second */
                    case Statement.Operation.Success :
                    {
                        this.pc = 0 ;
 
                        if ( statement.Operand1.Length > 0 )
                        {
                            this.socket.WriteLine ( statement.Operand1 ) ;
 
                            System.Threading.Thread.Sleep ( 1000 ) ;
                        }
 
                        break ;
                    }
 
                    /* On error; if a value was specified, set the PC to it  */
                    /* If there is text to send, send it and wait one second */
                    case Statement.Operation.Error :
                    {
                        if ( statement.Operand1.Length > 0 )
                        {
                            int.TryParse ( statement.Operand1 , out this.pc ) ;
                        }
 
                        if ( statement.Operand2.Length > 0 )
                        {
                            this.socket.WriteLine ( statement.Operand2 ) ;
 
                            System.Threading.Thread.Sleep ( 1000 ) ;
                        }
 
                        break ;
                    }
 
                    /* The Log command will close the current output stream  */
                    /* (except the first). And, if a path is specified,      */
                    /* will open a new output stream                         */
                    case Statement.Operation.Log :
                    {
                        lock ( this.outstream )
                        {
                            if ( this.outstream.Count > 1 )
                            {
                                this.outstream.Pop().Close() ;
                            }
 
                            if ( statement.Operand1.Length > 0 )
                            {
                                this.outstream.Push 
                                ( 
                                    PIEBALD.Lib.LibFil.GetExpandedFileInfo 
                                        ( statement.Operand1.Trim() ).CreateText() 
                                ) ;
                            }
                        }
 
                        break ;
                    }
 
                    /* The Define command will associate a name with a string */
                    case Statement.Operation.Define :
                    {
                        if ( statement.Operand2.Length == 0 )
                        {
                            this.knownstrings.Remove ( statement.Operand1 ) ;
                        }
                        else
                        {
                            this.knownstrings [ statement.Operand1 ] =
                                System.Web.HttpUtility.HtmlDecode 
				( statement.Operand2 ) ;
                        }
 
                        break ;
                    }
 
                    /* The Branch command adds, redefines, or 
			removes a branch definition */
                    case Statement.Operation.Branch :
                    {
                        if ( statement.Operand1.Length == 0 )
                        {
                            this.branches.Remove ( statement.Operand2 ) ;
                        }
                        else
                        {
                            this.branches [ statement.Operand2 ] = statement.Operand1 ;
                        }
 
                        break ;
                    }
 
                    /* The Wait command sets the text that ProcessResponse will seek */
                    /* in the data from the host                                     */
                    case Statement.Operation.Wait :
                    {
                        this.sought = statement.Operand1 ;
 
                        break ;
                    }
 
                    /* The Connect command attempts to connect to the specified host */
                    /* any Exceptions thrown by the connect will be treated as data  */
                    /* so that retries may be performed                              */
                    /* The process should then await the signal from ProcessResponse */
                    case Statement.Operation.Connect :
                    {
                        this.found =
                        (
                            ( this.sought.Length == 0 )
                        &&
                            ( this.branches.Count == 0 )
                        ) ;
 
                        try
                        {
                            this.socket.Connect ( statement.Operand1 ) ;
                        }
                        catch ( System.Exception err )
                        {
                            this.ProcessResponse ( err.ToString() ) ;
                        }
 
                        break ;
                    }
 
                    /* The Send command sends the specified text                      */
                    /* (and a carriage-return) to the host                            */
                    /* The process should then await the signal from ProcessResponse  */
                    case Statement.Operation.Send :
                    {
                        this.found =
                        (
                            ( this.sought.Length == 0 )
                        &&
                            ( this.branches.Count == 0 )
                        ) ;
 
                        this.socket.WriteLine ( statement.Operand1 ) ;
 
                        break ;
                    }
 
                    /* The Press command sends the named or specified text to the host */
                    /* (with no carriage-return)                                       */
                    /* The process should then await the signal from ProcessResponse   */
                    case Statement.Operation.Press :
                    {
                        this.found =
                        (
                            ( this.sought.Length == 0 )
                        &&
                            ( this.branches.Count == 0 )
                        ) ;
 
                        if ( this.knownstrings.ContainsKey ( statement.Operand1 ) )
                        {
                            this.socket.Write 
				( this.knownstrings [ statement.Operand1 ] ) ;
                        }
                        else
                        {
                            this.socket.Write ( statement.Operand1 ) ;
                        }
 
                        break ;
                    }
 
                    /* The Call command calls a subroutine */
                    case Statement.Operation.Call :
                    {
                        this.CallSubroutine ( statement.Operand1 ) ;
 
                        break ;
                    }
 
                    /* If the execution of the script reaches a subroutine       */
                    /* start command, it will not be executed,                   */
                    /* processing will continue after the end of the subroutine  */
                    case Statement.Operation.StartBracket :
                    case Statement.Operation.StartBrace :
                    {
                        this.pc = this.subroutines [ statement.Operand1 ].End ;
 
                        break ;
                    }
 
                    /* Upon return; bracket-type subroutines restore              */
                    /*  the program counter but not the sought and branch values  */
                    case Statement.Operation.EndBracket :
                    {
                        this.pc = this.substack.Pop().From ;
 
                        break ;
                    }
 
                    /* Upon return; brace-type subroutines restore the program counter */
                    /* as well as the sought and branch values                         */
                    case Statement.Operation.EndBrace   :
                    {
                        SubroutineCall sub = this.substack.Pop() ;
 
                        this.pc       = sub.From     ;
                        this.sought   = sub.Sought   ;
                        this.branches = sub.Branches ;
 
                        break ;
                    }
                }
            }
            else
            {
                System.Threading.Thread.Sleep ( 100 ) ;
            }
        }
    }
    catch ( System.Exception err )
    {
        this.pc *= -1 ;
 
        lock ( this.outstream )
        {
            while ( err != null )
            {
                this.outstream.Peek().WriteLine ( err ) ;
 
                err = err.InnerException ;
            }
        }
    }
    finally
    {
        this.socket.Close() ;
    }
 
    return ( this.pc ) ;
}

Using the Code

The included zip file contains all the code described here and several support files, some of which have their own articles or blog entries.

The primary way these classes are intended to be used is to cobble up the script in a string and pass it to the engine.

CommScriptDemo.cs

Here's a very simple example of this; it is basically the same demo program as I used in my TelnetSocket article, but now it creates and executes a script.

C#
namespace CommScriptDemo
{
    public static class CommScriptDemo
    {
        [System.STAThreadAttribute()]
        public static int
        Main
        (
            string[] args
        )
        {
            int result = 0 ;
 
            try
            {
                if ( args.Length > 3 )
                {
                   using
                   (
                       PIEBALD.Types.CommScript engine
                   =
                       new PIEBALD.Types.CommScript
                       (
                           new PIEBALD.Types.TelnetSocket()
                       )
                   )
                   {
                       string script = System.String.Format
                       (
                           @"
                           <Username:
                           @{0}
                           <Password:
                           >{1}
                           <mJB>
                           >{2}
                           "
                       ,
                           args [ 0 ]
                       ,
                           args [ 1 ]
                       ,
                           args [ 2 ]
                       ) ;
 
                       for ( int i = 3 ; i < args.Length ; i++ )
                       {
                           script += System.String.Format
                           (
                               @"
                               >{0}
                               "
                           ,
                               args [ i ]
                           ) ;
                       }
 
                       script +=
                       @"
                           $logout
                       " ;
 
                       System.Console.WriteLine ( script ) ;
 
                       string output ;
 
                       result = engine.ExecuteScript
                       (
                           script
                       ,
                           out output
                       ) ;
 
                       System.Console.WriteLine ( output ) ;
                   }
                }
                else
                {
                    System.Console.WriteLine ( 
                      "Syntax: CommScriptDemo address username password command..." ) ;
                }
            }
            catch ( System.Exception err )
            {
                System.Console.WriteLine ( err ) ;
            }
 
            return ( result ) ;
        }
    }
}

telnets.cs

There is also a demonstration/test application (telnets) and a BAT file for building it so you can test your own scripts (provided you have a telnet server available). You may also want to read Telnets.html, which is about all the documentation for it as I'm likely to write.

Demo.scr

The included Demo.scr (the extension "scr" is arbitrary, you can use anything you want) file is what I have been using to test the script engine as I have been tweaking it while writing this article. I have two AlphaServers (BADGER and WEASEL), the script will connect to BADGER (or WEASEL if it can't connect to BADGER), send the output of SHOW SYSTEM to a log file, and log out.

; Demo.scr -- Demonstrates some features of CommScript
<Username:
^Badger
<Password:
>this is where the username goes
<mJB>
>this is where the password goes
|Demo.log
>show system
|
<logged out
>logout
$
 
; Try connecting to Badger, on failure, try Weasel
{Badger
?Weasel=A connection attempt failed
@192.168.1.2
}
 
; Try connecting to Weasel, on failure, abort
{Weasel
?Abort=A connection attempt failed
@192.168.1.4
}
 
{Abort
!
}

Here's a script that performs a similar task on an SCO Unix system:

; telnets script to log on to XXXXXX and perform ps -a
?No Connection=telnetd:
?Retry=Login incorrect
<login:
@10.1.50.50
<Password:
>this is where the username goes
<TERM
>this is where the password goes
<$
>
>ps -a
$exit
 
[Retry
?No Connection=Login incorrect
<login:
>
<Password:
>this is where the username goes
<TERM
>this is where the password goes
]
 
[No Connection
!
]

History

  • 2010-03-22: First submitted
  • 2010-04-02: Modified IScriptableCommunicator due to ProcessCommunicator -- updated source code in the zip file

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)