Partager via


Creating F# FSX Script Files from FS Source Files

As you may know over the past few months I have been doing some work on FSharpChart. One of the source code changes that has been implemented over this time is the breakdown of the original single FSX script file into more manageable FS source files. However in doing this I was left with the need to compose the individual FS source files back into a single FSX script file; as folks may still wish to use this single file. Rather than do this composition manually for each release I decided to write a little utility script to achieve this.

In writing this code to generate the composite script file I wanted to do achieve several things:

  • Allow one to use a template to manage inclusion of reference and items like fsi.AddPrinter
  • Allow for the management of the namespaces and open statements
  • Easily allow for the inclusion of a multiple FS source files

For generating the FSharpChart FSX script file the template source I used is as follows:

Template File

#nowarn "40"
#if INTERACTIVE
#r "System.Windows.Forms.DataVisualization.dll"
#endif

namespace MSDN.FSharp.Charting

open System
open System.Collections
open System.Collections.Generic
open System.Drawing
open System.Reflection
open System.Runtime.InteropServices
open System.Windows.Forms
open System.Windows.Forms.DataVisualization.Charting

#file "Charting.ClipboardMetafileHelper.fs"
#file "Charting.ChartStyles.fs"
#file "Charting.ChartData.fs"
#file "Charting.ChartFormUtilities.fs"
#file "Charting.ChartTypes.fs"
#file "Charting.ChartControl.fs"
#file "Charting.FSharpChart.fs"
#file "Charting.ChartExtensions.fs"
#file "Charting.ChartStyleExtensions.fs"

#if INTERACTIVE
module InstallFsiAutoDisplay =
  fsi.AddPrinter(fun (ch:GenericChart) ->
    let frm = new Form(Visible = true, TopMost = true, Width = 700, Height = 500)
    let ctl = new ChartControl(ch, Dock = DockStyle.Fill)
    frm.Text <- ChartFormUtilities.ProvideTitle ch
    frm.Controls.Add(ctl)
    frm.Show()
    ctl.Focus() |> ignore
    "(Chart)")
#endif

The premise of this template file is that it is used to generate the FSX file where each #file tag is expanded to include the contents of the specified source file; where namespaces and open statements are modified to be consistent with the namespace of the template file.

In this example the template file defines the default namespace as “MSDN.FSharp.Charting”. As such when writing out the contents of a source file the base namespace element needed to be removed and the open statements modified to reflect this change. Thus in this case, with the default namespace being “MSDN.FSharp.Charting”, any open statement with this prefix needs to be transformed to exclude the default value; namely:

 open MSDN.FSharp.Charting.ChartTypes

needs to be transformed to:

 open ChartTypes

With this in mind here is the transform code:

Template Script

open System.Net

open System.Linq

open System.Collections.Generic

open System.IO

open System.Text.RegularExpressions

// --------------------------------------------------------------------------------------

// Utility for creating a single FSX file

// The code will parse through the template file and replace each #file with the appropriate file contents

let codeDir = __SOURCE_DIRECTORY__ + "\\..\\fsharpchart\\"

let templateName = __SOURCE_DIRECTORY__ + "\\FSharpChart.fstemplate"

let sourceName = __SOURCE_DIRECTORY__ + "\\..\\samples\\FSharpChart.fsx"

let startLine = "// ----------------------------------- "

let commentLine = "// "

let blankLine = ""

let filePattern = "^\#file\s+\"(?<filename>.*)\"$";

let codePattern = "^module|type|\[<.*>\]"

let openPattern = "^open MSDN\.FSharp\.Charting\.(?<openstatement>.*)$";

// Processes the contents of a code file

let isNotCodeStart (line:string) =

    if not (Regex.Match(line, codePattern).Success) then true

    else false

let getOpenStatements (lines:string list) (line:string) =

    let m = Regex.Match(line, openPattern, RegexOptions.IgnoreCase)

    if m.Success && m.Groups.Count = 2 then ("open " + m.Groups.["openstatement"].Value) :: lines

    else lines

    

let openFileLines filename =

    File.ReadAllLines(codeDir + filename)

    |> Seq.takeWhile isNotCodeStart

    |> Seq.fold getOpenStatements []

let codeFileLines filename =

    File.ReadAllLines(codeDir + filename)

    |> Seq.skipWhile isNotCodeStart

let processCode filename =

    [ yield startLine

      yield commentLine + filename

      yield startLine

      // yield any neccessary open statements

      match openFileLines filename with

      | [] -> ()

      | _ ->

        yield! (openFileLines filename)

        yield blankLine

      // yield the module/type code lines

      yield! (codeFileLines filename)

      yield blankLine]

// Define the output of the template file processing any #file elements

let (|MatchFileInclude|_|) (line:string) =

    let m = Regex.Match(line, filePattern, RegexOptions.IgnoreCase)

    if m.Success && m.Groups.Count = 2 then Some(m.Groups.["filename"].Value) else None

let templateFile =

    [ for line in File.ReadAllLines(templateName) do

        match line with

        | MatchFileInclude filename -> yield! (processCode filename)

        | _ -> yield line ]

// open and write the file contents

do

    use codeFile = File.CreateText(sourceName)

    templateFile

    |> Seq.iter codeFile.WriteLine

So what does the script do?

The templateFile list, which is written as the single FSX script file, is generated by one of two lines. Either the template source line is written directly to the output file or a #file tag is matched (MatchFileInclude) and the contents are processed.

In processing a code file firstly the open statements are written, through the openFileLines function, and secondly the remaining code lines are written, using the codeFileLines function. When executing these functions, processing occurs according to a the determination of when all the open statements have all been read and the first code line has been encountered. In this case the determination is made of this point in the source code through the regular expression match of a module, type or attribute statement.

Most of the code inspection is done through regular expressions. A file tag is located using an Active Pattern and the RegEx pattern:

 "^\#file\s+\"(?<filename>.*)\"$"

This way one can easily locate a #file tag and extract the source filename to be included. A similar mechanism is used for open statements where the captured expression is the new open statement value.

For the determination of the start of the code the RegEx pattern that is used for locating a module, type or attribute statement is:

 "^module|type|\[<.*>\]"

This code is a little specialized, as it does depend on some well structured source code file, but hopefully it does demonstrate that one can easily manage separate source code file, yet if necessary still easily deliver a single script file.