├── logo.png ├── test ├── LineParser.Tokens.fs ├── FSH.Tests.fsproj └── LineParser.Parts.fs ├── LICENSE ├── src ├── FSH.fsproj ├── Common.fs ├── Model.fs ├── LineWriter.fs ├── Interactive.fs ├── External.fs ├── Program.fs ├── LineParser.fs ├── LineReader.fs └── Builtins.fs ├── FSH.sln ├── .gitignore └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisPritchard/FSH/HEAD/logo.png -------------------------------------------------------------------------------- /test/LineParser.Tokens.fs: -------------------------------------------------------------------------------- 1 | module LineParser.Tokens 2 | 3 | open FsUnit 4 | open FsUnit.Xunit 5 | open Xunit 6 | open Model 7 | 8 | [] 9 | let ``Tokens parses blank as blank`` () = 10 | let result = tokens [] 11 | result |> should be Empty 12 | 13 | [] 14 | let ``Tokens parses no arg command`` () = 15 | let result = tokens ["test"] 16 | result |> should equal [Command ("test", [])] 17 | 18 | [] 19 | let ``Tokens parses a command with args`` () = 20 | let result = tokens ["test";"a1";"a2"] 21 | result |> should equal [Command ("test", ["a1";"a2"])] 22 | 23 | [] 24 | let ``Tokens parses code into a pipe into code`` () = 25 | let result = tokens ["(10)";"|>";"(printfn \"%i\")"] 26 | result |> should equal [Code "(10)";Pipe;Code "(printfn \"%i\")"] 27 | 28 | [] 29 | let ``Tokens parses empty space into whitespace`` () = 30 | let result = tokens [" ";"(test)";" "] 31 | result |> should equal [Whitespace 1;Code "(test)";Whitespace 2] -------------------------------------------------------------------------------- /test/FSH.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Exe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christopher Pritchard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/LineParser.Parts.fs: -------------------------------------------------------------------------------- 1 | module LineParser.Parts 2 | 3 | open FsUnit 4 | open FsUnit.Xunit 5 | open Xunit 6 | 7 | [] 8 | let ``Parts parses blank as blank`` () = 9 | let result = parts "" 10 | result |> should be Empty 11 | 12 | [] 13 | let ``Parts parses single as single`` () = 14 | let result = parts "test" 15 | result |> should equal ["test"] 16 | 17 | [] 18 | let ``Parts parses two words`` () = 19 | let result = parts "test1 test2" 20 | result |> should equal ["test1";"test2"] 21 | 22 | [] 23 | let ``Parts parses quotes properly`` () = 24 | let result = parts "\"test1 test2\"" 25 | result |> should equal ["\"test1 test2\""] 26 | 27 | [] 28 | let ``Parts parses brackets properly`` () = 29 | let result = parts "(test1 test2)" 30 | result |> should equal ["(test1 test2)"] 31 | 32 | [] 33 | let ``Parts parses nested brackets properly`` () = 34 | let result = parts "(test1 (test2) test3)" 35 | result |> should equal ["(test1 (test2) test3)"] 36 | 37 | [] 38 | let ``Parts escapes quotes`` () = 39 | let result = parts "\\\"test1 test2\\\"" 40 | result |> should equal ["\\\"test1";"test2\\\""] 41 | 42 | [] 43 | let ``Parts escapes spaces`` () = 44 | let result = parts "test1 te\ st2" 45 | result |> should equal ["test1";"te\ st2"] 46 | 47 | [] 48 | let ``Parts treats whitespace as empty tokens`` () = 49 | let result = parts "test1 test2" 50 | result |> should equal ["test1";" ";"test2"] -------------------------------------------------------------------------------- /src/FSH.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | FSH - the F# Shell 7 | Christopher Pritchard 8 | A command line shell written in F# with integrated F# Interactive 9 | https://github.com/ChrisPritchard/FSH 10 | 1.0.0 11 | FSH 12 | LICENSE 13 | fsharp shell 14 | win-x64;linux-x64;osx-x64 15 | true 16 | fsh 17 | ./nupkg 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Common.fs: -------------------------------------------------------------------------------- 1 | /// Common configuration like colours for the prompt, the prompt token and the like are defined here. 2 | /// Certain utility methods like changing the font colour are also defined here, for cross-app use. 3 | module Common 4 | 5 | open System 6 | open System.Runtime.InteropServices 7 | 8 | /// This is presented at the beginning of each line, e.g. FSH c:\> 9 | let promptName = "FSH" 10 | 11 | /// The colour scheme of the application, intended to be used with the apply method. 12 | module Colours = 13 | let title = ConsoleColor.Magenta 14 | let prompt = ConsoleColor.Magenta 15 | let goodOutput = ConsoleColor.Green 16 | let errorOutput = ConsoleColor.Red 17 | let command = ConsoleColor.Yellow 18 | let argument = ConsoleColor.Gray 19 | let code = ConsoleColor.Cyan 20 | let pipe = ConsoleColor.Green 21 | let neutral = ConsoleColor.Gray 22 | 23 | /// Sets the console foreground to the specified colour. 24 | /// Intended to be used with the colours module, e.g. apply Colours.prompt. 25 | let apply colour = Console.ForegroundColor <- colour 26 | 27 | /// This simple utility method switches colour to print a piece of text then switches back 28 | /// It is primarily used for the intro text of FSH 29 | let printc colour text = 30 | let current = Console.ForegroundColor 31 | apply colour 32 | printf "%s" text 33 | apply current 34 | 35 | let isWindows = RuntimeInformation.IsOSPlatform OSPlatform.Windows 36 | 37 | let newline = if isWindows then "\r\n" else "\n" 38 | 39 | /// When tabbing inside a code expressiom, this controls how many spaces are added. 40 | let codeSpaces = 4 41 | 42 | /// Thrown when launching a process that isn't an executable, e.g. a txt file. 43 | /// Defined so that the code can check for this, then try using explorer on windows. 44 | let notExecutableError = "The specified executable is not a valid application for this OS platform." -------------------------------------------------------------------------------- /FSH.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28803.352 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSH", "src\FSH.fsproj", "{EFFD2E5D-3591-429C-B629-9791BBA6BF3E}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{891C6051-064A-4AE3-B316-EF59910C5F3C}" 9 | ProjectSection(SolutionItems) = preProject 10 | LICENSE = LICENSE 11 | logo.png = logo.png 12 | README.md = README.md 13 | EndProjectSection 14 | EndProject 15 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSH.Tests", "test\FSH.Tests.fsproj", "{0F10ED7C-BA7F-452A-8C64-40CBEAF1C245}" 16 | EndProject 17 | Global 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {EFFD2E5D-3591-429C-B629-9791BBA6BF3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {EFFD2E5D-3591-429C-B629-9791BBA6BF3E}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {EFFD2E5D-3591-429C-B629-9791BBA6BF3E}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {EFFD2E5D-3591-429C-B629-9791BBA6BF3E}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {0F10ED7C-BA7F-452A-8C64-40CBEAF1C245}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {0F10ED7C-BA7F-452A-8C64-40CBEAF1C245}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {0F10ED7C-BA7F-452A-8C64-40CBEAF1C245}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {0F10ED7C-BA7F-452A-8C64-40CBEAF1C245}.Release|Any CPU.Build.0 = Release|Any CPU 31 | EndGlobalSection 32 | GlobalSection(SolutionProperties) = preSolution 33 | HideSolutionNode = FALSE 34 | EndGlobalSection 35 | GlobalSection(ExtensibilityGlobals) = postSolution 36 | SolutionGuid = {25201F31-90FB-402D-981D-CFD924C8E602} 37 | EndGlobalSection 38 | EndGlobal 39 | -------------------------------------------------------------------------------- /src/Model.fs: -------------------------------------------------------------------------------- 1 | /// FSH has very little need for its own types, as its largely about translating user input into direct actions. 2 | /// However, where custom types are needed they are defined here, along with closely related methods. 3 | module Model 4 | 5 | open System.Text 6 | 7 | /// Token is a DU used to tag parts of a read input. 8 | /// It is used for piping content, and cosmetically for colouring user input. 9 | type Token = 10 | /// A command is either a builtin or an external process to run. 11 | | Command of text:string * arguments:string list 12 | /// Code is wrapped F# to be passed through the integrated Interactive process. 13 | | Code of string 14 | /// Pipe indicates the output of what came before should be fed into what comes after. 15 | | Pipe 16 | /// Out is a final operation to send the output of what came before to a file. 17 | | Out of append: bool * fileName:string 18 | /// Represents a chunk of whitespace, used to preserve rendering as written, mostly. 19 | | Whitespace of length:int 20 | /// Like whitespace, is used for formatting mostly 21 | | Linebreak 22 | 23 | /// OutputWriter is a log out container used to capture the output of everything except for the last token in a command chain. 24 | /// It captures log lines using mutable internal string builders, which are then returned for the next token in the chain using the asResult member function. 25 | type OutputWriter () = 26 | let out = StringBuilder () 27 | let error = StringBuilder () 28 | /// Non-error output content. Will be piped to the next token or console out. 29 | member __.writeOut (s: string) = 30 | out.AppendLine s |> ignore 31 | /// Error output content. If this is used by a token's processor, then the full token process stops and the output is written to console out. 32 | member __.writeError (s: string) = 33 | error.AppendLine s |> ignore 34 | /// Used for piping, produces a Result that is evaluated to see if processing should continue, and with what. 35 | /// This also trims off errant new lines at the end of the content. 36 | member __.asResult () = 37 | let out = (string out).TrimEnd ([|'\r';'\n'|]) 38 | let error = (string error).TrimEnd ([|'\r';'\n'|]) 39 | if error <> "" then Error error else Ok out -------------------------------------------------------------------------------- /src/LineWriter.fs: -------------------------------------------------------------------------------- 1 | /// In this module is the print formatted method, which prints a line of text in its coloured, multi-line form to the output. 2 | /// This is used by the LineReader module, re-writing the current line to the Console out every time a character is read. 3 | module LineWriter 4 | 5 | open System 6 | open Model 7 | open Common 8 | open LineParser 9 | 10 | /// Writes out a list of tokens to the output, coloured appropriately. 11 | /// This also ensures the print out is aligned with the prompt 12 | let private writeTokens promptPos tokens = 13 | let align () = if Console.CursorLeft < promptPos then Console.CursorLeft <- promptPos 14 | let printAligned (s: string) = 15 | let lines = s.Split newline 16 | lines |> Array.iteri (fun i line -> 17 | align () 18 | if line <> "" then printf "%s " line 19 | if i <> lines.Length - 1 then 20 | printf "%s" newline) 21 | tokens 22 | |> List.iter (fun token -> 23 | match token with 24 | | Command (s, args) -> 25 | apply Colours.command 26 | printAligned s 27 | apply Colours.argument 28 | args |> Seq.iter printAligned 29 | | Code s -> 30 | apply Colours.code 31 | printAligned s 32 | | Pipe -> 33 | apply Colours.pipe 34 | printAligned "|>" 35 | | Out (append, fileName) -> 36 | apply Colours.pipe 37 | printAligned (if append then ">" else ">>") 38 | apply Colours.argument 39 | printAligned fileName 40 | | Whitespace n -> 41 | printAligned (String (' ', n)) 42 | | Linebreak -> 43 | printfn "") 44 | 45 | /// This ensures the output is cleared of prior characters before printing. 46 | /// It goes over the maximum number of lines of any prior entry, which ensures when jumping through history the output looks write. 47 | let private clearLines linesToClear startLine startPos = 48 | [1..linesToClear] 49 | |> Seq.iter (fun n -> 50 | let clearLine = String (' ', (Console.BufferWidth - Console.CursorLeft) - 4) 51 | if n <> linesToClear 52 | then printf "%s%s" clearLine newline 53 | else printf "%s" clearLine) 54 | Console.CursorTop <- startLine 55 | Console.CursorLeft <- startPos 56 | 57 | /// Takes a string (single or multiline) and prints it coloured by type to the output. 58 | /// By doing this everytime a character is read, changes to structure can be immediately reflected. 59 | let printFormatted (soFar: string) linesToClear startPos startLine = 60 | let parts = parts soFar // From LineParser.fs, breaks the input into its parts 61 | let tokens = tokens parts // Also from LineParser.fs, groups and tags the parts by type (e.g. Code) 62 | 63 | clearLines linesToClear startLine startPos 64 | 65 | writeTokens startPos tokens // Writes the types out, coloured. 66 | // finally, return the last non-whitespace token (used to control the behaviour of tab). 67 | tokens |> List.filter (function | Whitespace _ | Linebreak -> false | _ -> true) |> List.tryLast 68 | -------------------------------------------------------------------------------- /src/Interactive.fs: -------------------------------------------------------------------------------- 1 | /// Wrappers for F# Interactive, making it a little easier/consistent to use from the rest of FSH. 2 | /// Contains one primary class, Fsi. 3 | module Interactive 4 | 5 | open Microsoft.FSharp.Compiler.Interactive.Shell 6 | open System.IO 7 | 8 | /// This class instantiates the FSI instance, along with hidden reader/writer streams for FSI input/output/error. 9 | /// It provides several methods to interact with FSI or its streams in a simple fashion from outside the class. 10 | type Fsi () = 11 | 12 | // These streams and reader/writers are needed to instantiate FSI, but are otherwise not used. 13 | let inStream = new MemoryStream () 14 | let outStream = new MemoryStream () 15 | let errorStream = new MemoryStream () 16 | 17 | let inReader = new StreamReader (inStream) 18 | let outReader = new StreamWriter (outStream) 19 | let errorReader = new StreamWriter (errorStream) 20 | 21 | let fsiInstance = 22 | let fsiconfig = FsiEvaluationSession.GetDefaultConfiguration() 23 | let args = [| "fsi.exe"; "--noninteractive" |] 24 | FsiEvaluationSession.Create(fsiconfig, args, inReader, outReader, errorReader); 25 | 26 | /// This function takes a FsiValue (the result of a expression evaluation) and parses it for printing 27 | /// If a string this is simple, if a sequence of strings then each is written out individually, 28 | /// otherwise it just writes out a straight string conversion 29 | let printFsiValue writeOut (v: FsiValue) = 30 | match v.ReflectionValue with 31 | | :? string as s -> writeOut s 32 | | :? seq as sa -> sa |> Seq.iter writeOut 33 | | o -> writeOut (string o) 34 | 35 | /// For interactions, which don't result in an Fsi value but which might set the 'it' Fsi value, 36 | /// this function attempts to print 'it'. If it succeeds in doing so, 'it' is then blanked so that, 37 | /// future interactions that don't set 'it', don't mistakingly print an earlier set 'it'. it it it it it. 38 | let evaluateIt writeOut = 39 | let (result, error) = fsiInstance.EvalExpressionNonThrowing "it" 40 | if error.Length = 0 then 41 | match result with 42 | | Choice1Of2 (Some v) -> 43 | if v.ReflectionValue = box "" then () 44 | else printFsiValue writeOut v 45 | fsiInstance.EvalInteractionNonThrowing "let it = \"\"" |> ignore 46 | | _ -> () 47 | 48 | /// Processes an expression that must return a single value. 49 | /// Can't be used for declarations unless those are used to calculate said value. 50 | /// However can call a declaration made by EvalInteraction. 51 | member __.EvalExpression code writeOut writeError = 52 | let (result, error) = fsiInstance.EvalExpressionNonThrowing code 53 | if error.Length > 0 then 54 | error |> Seq.map (fun e -> string e) |> Seq.iter writeError 55 | else 56 | match result with 57 | | Choice1Of2 (Some v) -> printFsiValue writeOut v 58 | | Choice1Of2 None -> () 59 | | Choice2Of2 ex -> writeError ex.Message 60 | 61 | /// Processes a line as if written to the CLI of a FSI session. 62 | /// On success, will attempt to return the evaluation of 'it'. 63 | member __.EvalInteraction code writeOut writeError = 64 | let (result, error) = fsiInstance.EvalInteractionNonThrowing code 65 | if error.Length > 0 then 66 | error |> Seq.map (fun e -> string e) |> Seq.iter writeError 67 | else 68 | match result with 69 | | Choice1Of2 () -> evaluateIt writeOut 70 | | Choice2Of2 ex -> writeError ex.Message 71 | -------------------------------------------------------------------------------- /src/External.fs: -------------------------------------------------------------------------------- 1 | /// Contains the two slightly complicated functions used to start external processes. 2 | /// This module makes heavy use of Process, ProcessStartInfo and deals with std out and error streams. 3 | module External 4 | 5 | open System 6 | open System.Diagnostics 7 | open System.ComponentModel 8 | open Common 9 | 10 | /// Attempts to run an executable (not a builtin like cd or dir), ignoring the output 11 | /// This is used only when the process being run is the last process in the chain, and gives said process full control of the standard out 12 | let rec launchProcessWithoutCapture fileName args = 13 | use op = // As Process is IDisposable, 'use' here ensures it is cleaned up. 14 | ProcessStartInfo(fileName, args |> String.concat " ", 15 | UseShellExecute = false) 16 | |> fun i -> new Process (StartInfo = i) // Because Process is IDisposable, we use the recommended 'new' syntax. 17 | 18 | Console.CursorVisible <- true // so when receiving input from the child process, it has a cursor 19 | 20 | try 21 | apply Colours.goodOutput 22 | op.Start () |> ignore 23 | op.WaitForExit () 24 | with 25 | // Even on linux/osx, this is the exception thrown when launching failed 26 | | :? Win32Exception as ex -> 27 | // If on windows and the error the file isn't an executable, try piping through explorer. 28 | // This will cause explorer to query the registry for the default handler program. 29 | if ex.Message = notExecutableError && isWindows then 30 | launchProcessWithoutCapture "explorer" (fileName::args) 31 | else 32 | // Will USUALLY occur when trying to run a process that doesn't exist. 33 | // But running something you don't have rights too will also throw this. 34 | apply Colours.errorOutput 35 | printfn "%s: %s" fileName ex.Message 36 | // Hide the cursor. 37 | Console.CursorVisible <- false 38 | 39 | /// Attempts to run an executable (not a builtin like cd or dir) and to feed the result to the output. 40 | /// Differs from the above in that the run processes' outputs (out and error) are 'fed' to the writeOut/writeError methods. 41 | /// Processes that manipulate the shell (like Git Clone) won't work well with this (they'll work, but generate no output). 42 | let rec launchProcess fileName args writeOut writeError = 43 | use op = // As Process is IDisposable, 'use' here ensures it is cleaned up. 44 | ProcessStartInfo(fileName, args |> String.concat " ", 45 | UseShellExecute = false, 46 | RedirectStandardOutput = true, // Output is redirected so it can be captured by the events below. 47 | RedirectStandardError = true, // Error is also redirected for capture. 48 | RedirectStandardInput = false) // Note we don't redirect input, so that regular console input can be sent to the process. 49 | |> fun i -> new Process (StartInfo = i) // Because Process is IDisposable, we use the recommended 'new' syntax. 50 | 51 | op.OutputDataReceived.Add(fun e -> writeOut e.Data |> ignore) // These events capture output and error, and feed them into the writeMethods. 52 | op.ErrorDataReceived.Add(fun e -> writeError e.Data |> ignore) 53 | Console.CursorVisible <- true // so when receiving input from the child process, it has a cursor 54 | 55 | try 56 | op.Start () |> ignore 57 | 58 | op.BeginOutputReadLine () // Necessary so that the events above will fire: the process is asynchronously listened to. 59 | op.WaitForExit () 60 | op.CancelOutputRead () 61 | with 62 | // Even on linux/osx, this is the exception thrown when launching failed 63 | | :? Win32Exception as ex -> 64 | // If on windows and the error the file isn't an executable, try piping through explorer. 65 | // This will cause explorer to query the registry for the default handler program. 66 | if ex.Message = notExecutableError && isWindows then 67 | launchProcess "explorer" (fileName::args) writeOut writeError 68 | else 69 | // Will USUALLY occur when trying to run a process that doesn't exist. 70 | // But running something you don't have rights too will also throw this. 71 | writeError (sprintf "%s: %s" fileName ex.Message) 72 | // Hide the cursor. 73 | Console.CursorVisible <- false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /src/Program.fs: -------------------------------------------------------------------------------- 1 | /// The core shell loop is defined and run here. 2 | /// It prompts the user for input, then processes the result before repeating. 3 | /// In addition, some ancillary functions like process launching are also defined. 4 | 5 | open System 6 | open System.IO 7 | open Common 8 | open Model 9 | open Builtins 10 | open LineParser 11 | open LineReader 12 | open Interactive 13 | open External 14 | 15 | [] 16 | let main _ = 17 | 18 | // pick up the current console colour, so we can return to it on exit 19 | let currentConsoleColour = Console.ForegroundColor 20 | // Generally, the cursor is hidden when writing text that isn't from the user. 21 | // This is to prevent an ugly 'flicker'. 22 | Console.CursorVisible <- false 23 | 24 | // Below is the opening intro and help info lines of FSH. 25 | // They are invoked here so fsi can be instantiated, putting it in scope of code operations below. 26 | 27 | printc Colours.title (" -- FSH: FSharp Shell -- " + newline) 28 | printf "version: "; printc Colours.goodOutput ("2019.5.22.2" + newline) 29 | 30 | // Booting FSI takes a short but noticeable amount of time. 31 | printf "starting "; printc Colours.code "FSI"; printf "..." 32 | let fsi = Fsi () 33 | printfn "done" 34 | 35 | printf "For a list of commands type '" 36 | printc Colours.command "?" 37 | printf "' or '" 38 | printc Colours.command "help" 39 | printfn "'" 40 | 41 | printf "F# code can be executed via wrapping with '" 42 | printc Colours.code "(" 43 | printf "' and '" 44 | printc Colours.code ")" 45 | printfn "'" 46 | 47 | /// Attempts to run either help, a builtin, or an external process based on the given command and args 48 | let runCommand command args lastResult writeOut writeError = 49 | // Help (or ?) are special builtins, not part of the main builtin map (due to loading order). 50 | if command = "help" || command = "?" then 51 | help args writeOut writeError 52 | else 53 | match Map.tryFind command builtinMap with 54 | | Some f -> 55 | try 56 | f args writeOut writeError 57 | with 58 | | :? UnauthorizedAccessException as ex -> 59 | writeError (sprintf "%s failed fully/partially with error:%s%s" command newline ex.Message) 60 | | None -> // If no builtin is found, try to run the users input as a execute process command. 61 | if lastResult then 62 | launchProcessWithoutCapture command args 63 | else 64 | launchProcess command args writeOut writeError 65 | 66 | /// Attempts to run code as an expression or interaction. 67 | /// If the last result is not empty, it is set as a value that is applied to the code as a function parameter. 68 | let runCode lastResult (code: string) writeOut writeError = 69 | let source = 70 | if code.EndsWith ')' then code.[1..code.Length-2] 71 | else code.[1..] 72 | 73 | if lastResult = "" then 74 | fsi.EvalInteraction source writeOut writeError 75 | else 76 | // In the code below, the piped val is type annotated and piped into the expression 77 | // This reduces the need for command line code to have type annotations for string. 78 | let toEval = 79 | if code = "(*)" then // the (*) expression in special, as it treats the piped value as code to be evaluated 80 | lastResult 81 | elif lastResult.Contains newline then 82 | lastResult.Split ([|newline|], StringSplitOptions.RemoveEmptyEntries) // Treat a multiline last result as a string array. 83 | |> Array.map (fun s -> s.Replace("\"", "\\\"")) 84 | |> String.concat "\";\"" 85 | |> fun lastResult -> sprintf "let (piped: string[]) = [|\"%s\"|] in piped |> (%s)" lastResult source 86 | else // If no line breaks, the last result is piped in as a string. 87 | sprintf "let (piped: string) = \"%s\" in piped |> (%s)" lastResult source 88 | // Without the type annotations above, you would need to write (fun (s:string) -> ...) rather than just (fun s -> ...) 89 | fsi.EvalExpression toEval writeOut writeError 90 | 91 | /// The implementation of the '>> filename' token. Takes the piped in content and saves it to a file. 92 | let runOut content append path _ writeError = 93 | try 94 | if append then 95 | File.AppendAllText (path, content) 96 | else 97 | File.WriteAllText (path, content) 98 | with 99 | | ex -> 100 | writeError (sprintf "Error writing to out %s: %s" path ex.Message) 101 | 102 | /// Write methods provides the outputwriter object and write out, write error methods to be passed to a token evaluator. 103 | /// If this is the last token, these will print to Console out. 104 | /// Otherwise the outputWriter will fill with written content, to be piped to the next token. 105 | let writeMethods isLastToken = 106 | let output = OutputWriter () 107 | let writeOut, writeError = 108 | if isLastToken then 109 | (fun (s:string) -> 110 | apply Colours.goodOutput 111 | printfn "%s" s), 112 | (fun (s:string) -> 113 | apply Colours.errorOutput 114 | printfn "%s" s) 115 | else 116 | output.writeOut, output.writeError 117 | output, writeOut, writeError 118 | 119 | /// Handles running a given token, e.g. a command, pipe, code or out. 120 | /// Output is printed into string builders if intermediate tokens, or to the console out if the last. 121 | /// In this way, the last token can print in real time. 122 | let processToken isLastToken lastResult token = 123 | let output, writeOut, writeError = writeMethods isLastToken 124 | match lastResult with 125 | | Error ex -> 126 | if isLastToken then writeError ex // no processing occurs after the last result, so the error needs to be printed 127 | lastResult 128 | | Ok s -> 129 | match token with 130 | | Command (name, args) -> 131 | let args = if s <> "" then args @ [s] else args 132 | runCommand name args isLastToken writeOut writeError 133 | | Code code -> 134 | runCode s code writeOut writeError 135 | | Out (append, path) -> 136 | runOut s append path writeOut writeError 137 | | Pipe | Whitespace _ | Linebreak -> 138 | // Pipe uses the writeOut function to set the next content to be the pipedin last result 139 | // The Token DU also includes presentation only tokens, like linebreaks and whitespace. These do nothing and so act like pipes. 140 | writeOut s 141 | output.asResult () 142 | 143 | /// Splits up what has been entered into a set of tokens, then runs each in turn feeding the result of the previous as the input to the next. 144 | /// The last token to be processed prints directly to the console out. 145 | let executeEntered (s : string) = 146 | if String.IsNullOrWhiteSpace s then () // nothing specified so just loop 147 | else 148 | let parts = parts s 149 | let tokens = tokens parts 150 | let lastToken = List.last tokens 151 | 152 | (Ok "", tokens) // The fold starts with an empty string as the first 'piped' value 153 | ||> List.fold (fun lastResult token -> 154 | processToken (token = lastToken) lastResult token) 155 | |> ignore // The last token prints directly to the console out, and therefore the final result is ignored. 156 | 157 | /// The coreloop waits for input, runs that input, and repeats. 158 | /// It also handles the special exit command, quiting the loop and thus the process. 159 | /// This function is tail call optimised, so can loop forever until 'exit' is entered. 160 | let rec coreLoop prior = 161 | apply Colours.prompt 162 | printf "%s %s> " promptName (currentDir ()) 163 | // Here is called a special function from LineReader.fs that accepts tabs and the like. 164 | let entered = readLine prior 165 | if entered.Trim() = "exit" then () 166 | else 167 | executeEntered entered 168 | coreLoop (entered::prior) 169 | 170 | // Start the core loop with no prior command history. FSH begins! 171 | coreLoop [] 172 | 173 | // return to the original console colour 174 | Console.ForegroundColor <- currentConsoleColour 175 | Console.CursorVisible <- true 176 | 0 177 | -------------------------------------------------------------------------------- /src/LineParser.fs: -------------------------------------------------------------------------------- 1 | /// Methods for parsing a line of input into its constituent tokens and parts. 2 | /// Parts operates on a raw string, breaking it into logical chunks. 3 | /// Tokens takes these chunks, and groups them into process-able tokens. 4 | module LineParser 5 | 6 | open System 7 | open Model 8 | open Common 9 | 10 | /// Folds the given string list, joing ""'s into whitespace: "";"" becomes " " 11 | let private joinBlanks (raw: string list) = 12 | let parsed, final = 13 | // The fold state is the set of results so far, plus the last string (the first string, initially). 14 | // The fold operates over all chars but the first. 15 | (([], raw.[0]), raw.[1..]) 16 | ||> List.fold (fun (results, last) next -> 17 | if next = "" && last = "" then 18 | results, " " 19 | elif next = "" && String.IsNullOrWhiteSpace last then 20 | results, last + " " 21 | elif last = "" then 22 | results, next 23 | elif last = newline then 24 | last::results, next 25 | elif String.IsNullOrWhiteSpace last then 26 | last.[0..last.Length - 2]::results, next 27 | else 28 | last::results, next) 29 | List.rev (final::parsed) 30 | 31 | /// Splits up a string into tokens, accounting for escaped spaces and quote/code wrapping, 32 | /// e.g. '"hello" world "this is a" test\ test' would be processed as ["hello";"world";"this is a";"test test"]. 33 | /// Note the use of an inner function and the 'soFarPlus' accumulator: this ensures the recursion is 'tail recursive', by making the recursive call the final call of the function. 34 | /// Even though this is likely not necessary for command line parsing, its still a good technique to learn to avoid unexpected stack overflows. 35 | let parts s = 36 | // The internal recursive function processes the input one char at a time via a list computation expression. 37 | // This affords a good deal of control over the output, and is functional/immutable. 38 | // The parameters are: results soFar, an option type determining whether the current character is in a 'wrapped' section (e.g. '"hello"' or '(world)'), 39 | // what the last character was (used to check for escaping like '\"' or '\ '), whats left to process and an accumuluator function, that allows tail recursion 40 | // by shifting where the soFar and recursively parsed tail/remainder is pieced together. 41 | let rec parts soFar wrapped last remainder acc = 42 | if remainder = "" then 43 | // If a wrapping op was in progress, add the start token to be faithful to user input. 44 | match wrapped with 45 | | Some (c, _) -> 46 | // { is a special wrap token that indicates an inner code string (used so parentheses 47 | // in such strings are not captured in the bracket push - see issue #2 on Github) 48 | let ch = if c = '{' || c = '[' then "(" else string c 49 | acc [ch + soFar] 50 | | None -> acc (if soFar <> "" then [soFar] else []) 51 | else 52 | let c, next = remainder.[0], remainder.[1..] 53 | match c, wrapped with 54 | // brackets control code sections, changing how inner characters are parsed 55 | | '(', None when soFar = "" -> 56 | let nextWrapped = Some ('(', 1) 57 | parts soFar nextWrapped last next acc 58 | | '(', Some ('(', n) -> // Bracket pushing. 59 | let nextWrapped = Some ('(', n + 1) 60 | parts (soFar + "(") nextWrapped last next acc 61 | | ')', Some ('(', 1) when last <> '\\' -> 62 | parts "" None last next (fun next -> acc (sprintf "(%s)" soFar::next)) 63 | | ')', Some ('(', n) when last <> '\\' -> // Bracket popping. 64 | let nextWrapped = Some ('(', n - 1) 65 | parts (soFar + ")") nextWrapped last next acc 66 | // quotes are primarily used to wrap arguments with internal spaces 67 | | '\"', None when soFar = "" -> 68 | parts soFar (Some ('\"', 1)) last next acc // Quotes always have a 'stack' of 1, as they cant be pushed/popped like brackets. 69 | | '\"', Some ('\"', 1) when last <> '\\' -> 70 | parts "" None last next (fun next -> acc (sprintf "\"%s\"" soFar::next)) 71 | // if in code (wrapping '(') then quotes indicate a string. { and [ prevent the bracket push failing 72 | | '\"', Some ('(', n) -> 73 | parts (soFar + "\"") (Some ('{', n)) last next acc 74 | | '\"', Some ('{', n) -> 75 | parts (soFar + "\"") (Some ('(', n)) last next acc 76 | | ''', Some ('(', n) -> 77 | parts (soFar + "'") (Some ('[', n)) last next acc 78 | | ''', Some ('[', n) -> 79 | parts (soFar + "'") (Some ('(', n)) last next acc 80 | // spaces sperate parts, as long as not in a wrapped section 81 | | ' ', None when last <> '\\' -> 82 | parts "" None last next (fun next -> acc (soFar::next)) 83 | | '\n', None when soFar = "\r" || soFar = "" -> 84 | parts "" None last next (fun next -> acc (newline::next)) 85 | | _ -> 86 | parts (soFar + string c) wrapped c next acc 87 | let raw = parts "" None ' ' s id // The blank space here, ' ', is just a dummy initial state that ensures the first char will be treated as a new token. 88 | if List.isEmpty raw then raw 89 | else joinBlanks raw // A final fold is used to combine whitespace blocks: e.g. "";"";"" becomes " " 90 | 91 | /// Tokens takes a set of parts (returned by previous method - a string array) and converts it into `operational tokens`. 92 | /// E.g. echo hello world |> (fun (s:string) -> s.ToUpper()) >> out.txt would become a [Command; Pipe; Code; Out] 93 | /// Note again the use of an inner function and the 'soFarPlus' accumulator, as above with parts 94 | let tokens partlist = 95 | let rec tokens partlist acc = 96 | match partlist with 97 | | [] -> acc [] 98 | | head::remainder -> 99 | match head with 100 | | (s: string) when s.StartsWith newline -> 101 | tokens remainder (fun next -> acc (Linebreak::next)) 102 | | s when String.IsNullOrWhiteSpace s -> 103 | tokens remainder (fun next -> acc (Whitespace s.Length::next)) 104 | | "|>" -> 105 | tokens remainder (fun next -> acc (Pipe::next)) 106 | | ">" -> 107 | match remainder with 108 | | path::_ -> acc [Out (false, path)] 109 | | _ -> acc [Out (true, "")] 110 | | ">>" -> 111 | match remainder with 112 | | path::_ -> acc [Out (true, path)] 113 | | _ -> acc [Out (false, "")] 114 | | s when s.[0] = '(' && (remainder = [] || s.[s.Length - 1] = ')') -> 115 | tokens remainder (fun next -> acc (Code s::next)) 116 | | command -> 117 | let rec findArgs list = 118 | match list with 119 | | [] | "|>"::_ | ">"::_ | ">>"::_ -> [] 120 | | ""::remainder -> 121 | " "::findArgs remainder 122 | | head::remainder -> 123 | head::findArgs remainder 124 | let args = findArgs remainder 125 | tokens remainder.[args.Length..] (fun next -> acc (Command (command, args)::next)) 126 | tokens partlist id 127 | 128 | // Mutable version of the above. This was used first during development, but the recursive version is arguably simpler. 129 | // The recursive version also has the advantage in that it doesn't require the incrementing of an index - something I consistently forgot 130 | // to do whenever I modified this, and therefore caused infinite loops :D 131 | (* 132 | let tokens partlist = 133 | [ 134 | let mutable i = 0 135 | let max = List.length partlist - 1 136 | while i <= max do 137 | let part = partlist.[i] 138 | if part = newline then 139 | yield Linebreak 140 | i <- i + 1 141 | elif String.IsNullOrWhiteSpace part then 142 | yield Whitespace part.Length 143 | i <- i + 1 144 | elif part = "|>" then 145 | yield Pipe 146 | i <- i + 1 147 | elif part = ">>" && i < max then 148 | yield Out partlist.[i + 1] 149 | i <- i + 2 150 | elif part.[0] = '(' && (i = max || part.[part.Length - 1] = ')') then 151 | yield Code part 152 | i <- i + 1 153 | else 154 | let command = part 155 | let args = [ 156 | let mutable valid = true 157 | i <- i + 1 158 | while valid && i <= max do 159 | let argOption = partlist.[i] 160 | if argOption = "|>" || argOption = ">>" then 161 | valid <- false 162 | i <- i - 1 163 | elif argOption = "" then 164 | yield " " 165 | else 166 | yield argOption 167 | i <- i + 1 168 | ] 169 | yield Command (command, args) 170 | ] 171 | *) -------------------------------------------------------------------------------- /src/LineReader.fs: -------------------------------------------------------------------------------- 1 | /// Contains the readLine method, and private support methods, which reimplement Console.ReadLine with support for tab completion. 2 | /// Tab completion attempts to finish the users current token from files, directories and the builtin list. 3 | /// As tab completion requires we intercept the readkey, we therefore need to implement 4 | /// the rest of the console readline functionality, like arrows and backspace. 5 | module LineReader 6 | 7 | open System 8 | open System.IO 9 | open Common 10 | open Builtins 11 | open LineParser 12 | open LineWriter 13 | open Model 14 | 15 | /// For a set of strings, will return the common start string. 16 | let private common startIndex (candidates : string list) = 17 | // finds the first index that is longer than a candidate or for which two or more candidates differ 18 | let uncommonPoint = 19 | [startIndex..Console.BufferWidth] 20 | |> List.find (fun i -> 21 | if i >= candidates.[0].Length then true 22 | else 23 | let charAt = candidates.[0].[i] 24 | List.tryFind (fun (c : string) -> 25 | c.Length = i || c.[i] <> charAt) candidates <> None) 26 | // return the component prior to this found index 27 | candidates.[0].[0..uncommonPoint-1] 28 | 29 | /// Attempts to find the closest matching file, directory or builtin for the final path token. 30 | /// If multiple candidates are found, only the common content is returned. 31 | let private attemptTabCompletion soFar pos = 32 | let last = parts soFar |> List.last 33 | let last = if last.StartsWith "\"" then last.[1..] else last 34 | 35 | let asPath = if Path.IsPathRooted last then last else Path.Combine(currentDir (), last) 36 | let asDirectory = Path.GetDirectoryName asPath 37 | let asFile = Path.GetFileName asPath 38 | 39 | try 40 | let files = Directory.GetFiles asDirectory |> Array.map Path.GetFileName |> Array.toList 41 | let folders = Directory.GetDirectories asDirectory |> Array.map Path.GetFileName |> Array.toList 42 | let allOptions = files @ folders @ List.map fst builtins 43 | 44 | let candidates = allOptions |> List.filter (fun n -> n.ToLower().StartsWith(asFile.ToLower())) 45 | 46 | if candidates.Length = 0 then 47 | soFar, pos // no change 48 | else 49 | let matched = if candidates.Length = 1 then candidates.[0] else common asFile.Length candidates 50 | let finalDir = if asDirectory = currentDir () then "" else asDirectory 51 | let soFar = soFar.[0..pos-last.Length-1] + Path.Combine(finalDir, matched) 52 | soFar, soFar.Length 53 | with 54 | | :? IOException -> soFar, pos // Invalid path or file access, so treat as not completable. 55 | 56 | /// Reads a line of input from the user, enhanced for automatic tabbing and the like. 57 | /// Prior is a list of prior input lines, used for history navigation 58 | let readLine (prior: string list) = 59 | let startPos = Console.CursorLeft 60 | let startLine = Console.CursorTop 61 | // The maximum number of lines to clear is calculated based on the prior history. 62 | // E.g. if there is a prior command that is four lines long, then whenever the output is printed, 63 | // four lines are cleared. 64 | let linesToClear = ""::prior |> Seq.map (fun p -> p.Split newline |> Seq.length) |> Seq.max 65 | 66 | /// For operations that alter the current string at pos (e.g. delete) 67 | /// the last line position in the total string needs to be determined. 68 | let lastLineStart (soFar: string) = 69 | let lastLineBreak = soFar.LastIndexOf newline 70 | if lastLineBreak = -1 then 0 else lastLineBreak + newline.Length 71 | 72 | /// This recursively prompts for input from the user, producing a final string result on the reception of the Enter key. 73 | /// As its recursive call is always the last statement, this code is tail recursive. 74 | let rec reader priorIndex (soFar: string) pos = 75 | 76 | // Ensure the console buffer is wide enough for our text. 77 | // This change solved so, so many issues. 78 | let bufferLengthRequired = startPos + soFar.Length 79 | if isWindows && bufferLengthRequired >= Console.BufferWidth then 80 | Console.BufferWidth <- bufferLengthRequired + 1 81 | 82 | // By printing out the current content of the line after every 83 | // char, implementing backspace and delete becomes easier. 84 | Console.CursorVisible <- false 85 | Console.CursorLeft <- startPos 86 | Console.CursorTop <- startLine 87 | // The printFormatted function (from the LineWriter module) converts to token types for colouring. 88 | // As part of this, the last token type can be retrieved which alters how some of the keys below work (specifically tabbing, 89 | // which does tab completion for commands but tab spaces for code). 90 | let lastTokenType = printFormatted soFar linesToClear startPos startLine 91 | Console.CursorLeft <- startPos + pos 92 | Console.CursorVisible <- true 93 | 94 | // Blocks here until a key is read. 95 | let next = Console.ReadKey true 96 | 97 | let isShiftPressed = 98 | [ ConsoleModifiers.Shift; ConsoleModifiers.Control; ConsoleModifiers.Alt ] 99 | |> List.exists (fun m -> next.Modifiers = m) 100 | 101 | // The user's key is evaluated as either: Enter (without Shift/Alt/Control) meaning done, 102 | // Enter with Shift/Alt/Control meaning newline 103 | // a control key like Backspace, Delete, Arrows (including history up/down using the prior commands list), 104 | // or, if none of the above, a character to append to the 'soFar' string. 105 | match next.Key with 106 | | ConsoleKey.Enter when not isShiftPressed -> 107 | Console.CursorVisible <- false // As reading is done, Hide the cursor. 108 | printfn "" // Write a final newline. 109 | soFar 110 | // Enter with shift/control/alt pressed adds a new line, aligned with the prompt position. 111 | | ConsoleKey.Enter -> 112 | reader priorIndex (soFar + newline) 0 113 | | ConsoleKey.Backspace when Console.CursorLeft <> startPos -> 114 | let relPos = lastLineStart soFar + pos 115 | let nextSoFar = soFar.[0..relPos-2] + soFar.[relPos..] 116 | let nextPos = max 0 (pos - 1) 117 | reader priorIndex nextSoFar nextPos 118 | | ConsoleKey.Delete -> 119 | let relPos = lastLineStart soFar + pos 120 | let nextSoFar = if soFar = "" || relPos = soFar.Length then soFar else soFar.[0..relPos-1] + soFar.[relPos+1..] 121 | reader priorIndex nextSoFar pos 122 | // Left and Right change the position on the current line, allowing users to insert characters. 123 | | ConsoleKey.LeftArrow -> 124 | let nextPos = max 0 (pos - 1) 125 | reader priorIndex soFar nextPos 126 | | ConsoleKey.RightArrow -> 127 | let nextPos = min soFar.Length (pos + 1) 128 | reader priorIndex soFar nextPos 129 | // Up and Down replace the current soFar with the relevant history item from the 'prior' list. 130 | | ConsoleKey.UpArrow when priorIndex < List.length prior - 1 -> 131 | let nextIndex = priorIndex + 1 132 | let nextSoFar = prior.[nextIndex] 133 | let nextPos = nextSoFar.Length - lastLineStart nextSoFar 134 | reader nextIndex nextSoFar nextPos 135 | | ConsoleKey.DownArrow when priorIndex > 0 -> 136 | let nextIndex = priorIndex - 1 137 | let nextSoFar = prior.[nextIndex] 138 | let nextPos = nextSoFar.Length - lastLineStart nextSoFar 139 | reader nextIndex nextSoFar nextPos 140 | // Like Left and Right, Home and End jumps to the start or end of the current line. 141 | | ConsoleKey.Home -> 142 | reader priorIndex soFar 0 143 | | ConsoleKey.End -> 144 | let nextPos = (soFar.Length - lastLineStart soFar) 145 | reader priorIndex soFar nextPos 146 | // Tab is complex, in that if in code it adds spaces to the line, and if not in code it attempts to finish a path 147 | // or command given the last token in soFar. Nothing is done if the tab completion would exceed the maximum line length. 148 | | ConsoleKey.Tab when soFar <> "" -> 149 | let newSoFar, newPos = 150 | match lastTokenType with 151 | | Some (Code code) -> 152 | if not (code.Contains newline) then soFar, pos 153 | else 154 | let lineStart = lastLineStart soFar 155 | let newSoFar = soFar.[..lineStart-1] + String (' ', codeSpaces) + soFar.[lineStart..] 156 | newSoFar, pos + codeSpaces 157 | | _ -> 158 | attemptTabCompletion soFar pos 159 | reader priorIndex newSoFar newPos 160 | // Finally, if none of the above and the key pressed is not a control char (e.g. Alt, Esc), it is appended. 161 | // Unless the line is already at max length, in which case nothing is done. 162 | | _ -> 163 | let c = next.KeyChar 164 | let lineStart = lastLineStart soFar 165 | if not (Char.IsControl c) then 166 | let relPos = lineStart + pos 167 | reader priorIndex (soFar.[0..relPos-1] + string c + soFar.[relPos..]) (pos + 1) 168 | else 169 | reader priorIndex soFar pos 170 | 171 | reader -1 "" 0 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FSH 2 | 3 | FSH (**F**# **Sh**ell - pronounced like 'fish') is a shell, like CMD, Powershell or Bash, entirely written in F#. 4 | 5 | > **Update:** Recently upgraded to NET 5, no issues so far! 6 | 7 | > **Update Update:** And now its NET 6, poggers 8 | 9 | > Note this is sort of a proof of concept, which while functional (ha), lacks a lot of functionality compared to a regular shell like bash, powershell or even cmd (e.g. `ls`, but no `ls -la`). But... you can run F#, so thats cool :) 10 | 11 | ## Basic Interactions 12 | 13 | In addition to normal shell features (folder navigation, creation, deletion, echo etc.), FSH also supports piping and F# interactive. You can run code to declare F# functions, then use those functions as part of a piping operation with the other builtin commands. You can also just declare anonymous expressions for piping. 14 | 15 | To demonstrate what this means, using FSH, you could type a line like: 16 | 17 | echo hello world |> (fun s -> s.ToUpper()) > result.txt 18 | 19 | Which would create a text file in the current directory called result.txt, containing the text `HELLO WORLD` 20 | 21 | You can see in the above sample that to pipe between processable 'tokens' (like a command or code) you use `|>` just like in F#. And to run F# code, you wrap it with `(` and `)`. In the sample the built-in command `echo` with the arguments `hello world` is piped to the code expression `fun s -> s.ToUpper()`, then piped again to the result.txt file (`>` is a special pipe that sends to a file rather than console out - >> is also supported to append rather than overrite). 22 | 23 | As you type the above, the text is coloured automatically by the type of expression you are typing. 24 | 25 | ## Further Examples 26 | 27 | For something more advanced, you could implement a simple 'grep' command (grep is not built in to FSH by default): 28 | 29 | (let grep (s:string) arr = 30 | arr |> Array.filter (fun line -> line.Contains(s))) 31 | 32 | (**Note:** that shift/alt/control+enter will go to a new line, useful for code. Tabbing (four spaces) works as well, shunting the current line forward. Also, by piping arr on the second line, the line parameter does not need a type annotation.) 33 | 34 | (**Further Note for Linux/OSX:** the terminal and System.Console.ConsoleModifiers don't work very well together, so aside from shift, control and alt also work with enter for new lines. Find what works for you - alt+enter worked for me on Ubuntu) 35 | 36 | (**Further Further Note for Linux/OSX:** there is an issue with newlines on these platforms, due to inconsistencies between how System.Console, TextWriter, CursorTop (or something) work. I am presently investigating these issues (issue #10 and #14)) 37 | 38 | Then use this like: 39 | 40 | ls |> (grep ".dll") 41 | 42 | This will list all dll files in the current directory, one per line. 43 | 44 | This can be used with any token, not just 'built-ins' like `echo` and `ls`. For example, if you wanted to get the list of templates in the dotnet CLI that support F#: 45 | 46 | dotnet new |> (fun sa -> sa |> Seq.filter (fun s -> s.Contains "F#")) 47 | 48 | On my machine, this will print: 49 | 50 | Console Application console [C#], F#, VB Common/Console 51 | Class library classlib [C#], F#, VB Common/Library 52 | Unit Test Project mstest [C#], F#, VB Test/MSTest 53 | ... and so on 54 | 55 | ## Installing and Running 56 | 57 | FSH uses **.NET 5**. To build, you will need to install this SDK from [here](https://dotnet.microsoft.com/download/dotnet/5.0). 58 | 59 | It has been tested in Linux (via Ubuntu 18 under WSL and on a VM) and on Max OSX, with some minor issues (see [Issues](https://github.com/ChrisPritchard/FSH/issues) here on github). 60 | 61 | To run on the command line, navigate to the **/src** directory and use the command `dotnet run` 62 | 63 | There are also some XUnit/FsUnit tests defined under /test, there for testing various input scenarios through the parts/tokenisation functions. Run them (if you wish) via `dotnet test` in the **/test** directory. 64 | 65 | **Important**: This was built in a month, and shells are a lot more complicated than they look. There *are* bugs, but *hopefully* during casual use you won't find them :) 66 | 67 | ## "Builtins" 68 | 69 | Any shell has a number of commands that you can run, aside from things like external processes or in the case of FSH, code. FSH supports a limited set of these, all with basic but effective functionality. To get a list of these type **help** or **?**. Some common examples are: 70 | 71 | - **cd [path]**: change the current directory. E.g. `cd /` or `cd ..` to go to root or one directory up. 72 | - **echo [text]**: prints the arguments out as text - quite useful for piping 73 | - **cat [filename]** reads a file and prints out its output - also useful for piping 74 | - **> [filename]** prints out the piped in content into a file (this overwrites; **>>** appends instead). 75 | 76 | There are almost a dozen further commands, like **rm**, **mkdir**, **env** etc. As mentioned in the intro, these commands do their very basic functions, and don't support the arguments you would expect from a regular shell, but they serve to prove the concept. 77 | 78 | All builtins are defined in [Builtins.fs](/src/Builtins.fs) 79 | 80 | ## Code expressions 81 | 82 | Code expressions are written by wrapping code with `(` and `)`. Only the outer most parentheses are parsed as wrappers, so for example `(fun (s:string) -> s.ToUpper())` is perfectly fine. 83 | 84 | If a code expression is the first expression on a line, then it is treated as a **FSI Interaction**. Otherwise it is treated as a **FSI Expression**. What is the difference? 85 | 86 | - A FSI Interaction can be any valid F# code. `let x = 10`, `50 * 5`, `let grep (s: string) arr = arr |> Array.filter (fun line -> line.Contains(s))` are all valid interactions. 87 | - A FSI Expression must be F# that evaluates to a value. `let x = 10` is not a valid expression, but `let x = 10 in x` *is* valid as it will evaluate to 10. Likewise, defining functions like `let grep ...` above is not valid unless it is immediately used in a `in` expression, like `let grep... in grep ".dll"` (which will also locally scope grep, making it not usable again without being redefined). 88 | 89 | ### Piped values 90 | 91 | Because expressions are only run when there is a value to be piped into them, in FSH they have the additional contraint that they must support a parameter that matches the piped value (either a string or a string array). The reason is that in `echo "test" |> (fun s -> s.ToUpper())`, the code that is passed to the hidden FSI process is actually `let piped = "test" in piped |> (fun s -> s.ToUpper())` 92 | 93 | Normally the piped value will be a string, as above. However if the prior result contains line breaks, it will be split into an array. E.g. ls or dir, if used on a directory with more than one file, will produce a piped string array for code expressions to use. 94 | 95 | An expression can return either a value or a string array. If the latter, than it will be concatenated with line breaks for output printing. Otherwise it is converted to a string. 96 | 97 | ### Loading or composing code 98 | 99 | There is a special code expression, `(*)`. When This token is parsed, it treats the *piped value as code*. 100 | 101 | For example, say you ran this line: 102 | 103 | echo printfn "hello world from FSH!" > greeter.fs 104 | 105 | You could then read and evaluate this file using: 106 | 107 | cat greeter.fs |> (*) 108 | 109 | Resulting in the output "hello world from FSH!" being printed to the console. 110 | Likewise, you could compose without a file, like: 111 | 112 | echo printfn "FSH says Hello World" |> (*) 113 | 114 | To get "FSH says Hello World!" printed to the console. 115 | 116 | ### Interactions and 'it' 117 | 118 | By default, FSI will evaluate an interaction and return `unit` if successful. 119 | In order to make interactions useful as the first token of a piped expression, after an interaction is evaluated FSH will attempt to evaluate the expression 'it' and send its value on to the next token (or console out, if the last expression). 120 | 121 | If this evaluation fails, then an empty string is passed along. Otherwise, if found, then 'it' is *set* to "", before the value is passed along. This is so that 'it', is cleared in case the use of later interaction doesn't overwrite it. 122 | 123 | Code is run in [Interactive.fs](/src/Interactive.fs), which wraps FSI. For further details please follow the code in there. 124 | 125 | ## Why? 126 | 127 | This has been developed for the **[2019 F# Applied Competition](http://foundation.fsharp.org/applied_fsharp_challenge)**, as an educational project, over the course of about a month. 128 | 129 | > **Update:** It won in one of the competition categories, I am proud to say. Full results [here](http://foundation.fsharp.org/results_applied_fsharp_2019). 130 | 131 | The idea came from PowerShell, which as a developer who works primarily on windows machines, is my default shell. However, PowerShell syntax is very verbose, especially when using .NET code in-line; a shell with a simpler, more bash- or cmd-like syntax combined with the light syntax and type inferrence of F# seemed like a good thing to make into a proof-of-concept. 132 | 133 | As it stands, FSH is not really intended to be used in anger: while it supports a host of builtins, and is quite extensible with its FSI integration, it is missing a lot of features, for example numerous flags on the default builtins (ls/dir just list the local directory, but don't for example, have any flags to provide more info or multi page support etc). 134 | 135 | If someone wanted to take this, and extend it, they are free to do so: its under MIT. But primarily FSH serves as an educational example of: 136 | 137 | - Creating a shell, able to run its own commands and external executables. 138 | - Various folder and file manipulation techniques. 139 | - A custom ReadLine method, that supports advanced features like line shifting and history (defined in [LineReader.fs](/src/LineReader.fs)) 140 | - String tokensisation (defined in [LineParser.fs](/src/LineParser.fs)) 141 | - Integrating F# Interactive into a project in a useful way. 142 | 143 | All code files are helpfully commented. Start with [Program.fs](/src/Program.fs) and follow its structure from there. 144 | 145 | I hope it is useful to readers on the above topics :) 146 | 147 | **Finally**: I know there is another shell called 'fish' out there (the **[Friendly Interactive Shell](https://github.com/fish-shell/fish-shell)**). But what else would I call a F# Shell but FSH? 148 | -------------------------------------------------------------------------------- /src/Builtins.fs: -------------------------------------------------------------------------------- 1 | /// All builtins are defined and aggregated here. 2 | /// A builtin is a custom command that provides a shell function, e.g. cd which changes the shells current directory. 3 | /// The builtins are each a custom function that takes input arguments. 4 | /// The final 'builtin' list exposes these functions and the command that invokes them (usually the same, but there are synonyms like ? for help) 5 | /// to the processCommand function in Program.fs 6 | module Builtins 7 | 8 | open System 9 | open System.IO 10 | open System.Collections 11 | open Common 12 | 13 | /// Returns the current process directory. By default this is where it was started, and can be changed with the cd builtin. 14 | let currentDir () = Directory.GetCurrentDirectory () 15 | 16 | /// Clears the console window. 17 | let private clear _ _ _ = 18 | Console.Clear () 19 | 20 | /// Reads out the arguments passed into the output. 21 | let private echo args writeOut _ = 22 | writeOut (sprintf "%s" (String.concat " " args)) 23 | 24 | /// Lists the contents of the current directory. 25 | let private dir args writeOut _ = 26 | let searchPath = Path.Combine(currentDir (), if List.isEmpty args then "" else args.[0]) 27 | let searchPattern = if List.length args < 2 then "*" else args.[1] 28 | 29 | if File.Exists searchPath then 30 | writeOut (sprintf "%s" (Path.GetFileName searchPath)) 31 | else 32 | let finalPath, finalPattern = 33 | if Directory.Exists searchPath then searchPath, searchPattern 34 | else if searchPattern = "*" then Path.GetDirectoryName searchPath, Path.GetFileName searchPath 35 | else Path.GetDirectoryName searchPath, searchPattern 36 | 37 | Directory.GetDirectories (finalPath, finalPattern) 38 | |> Seq.map (Path.GetFileName >> sprintf "%s/") 39 | |> Seq.iter writeOut 40 | 41 | Directory.GetFiles (finalPath, finalPattern) 42 | |> Seq.map (Path.GetFileName >> sprintf "%s") 43 | |> Seq.iter writeOut 44 | 45 | /// Changes the curent process directory. 46 | let private cd args _ writeError = 47 | if List.isEmpty args then writeError "no path specified" 48 | elif args.[0] = "\\" && isWindows then Directory.SetCurrentDirectory "/" 49 | elif args.[0] = "~" then 50 | Directory.SetCurrentDirectory(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)) 51 | else 52 | let newPath = Path.Combine (currentDir (), args.[0]) 53 | let newPath = if newPath.EndsWith "/" then newPath else newPath + "/" 54 | if Directory.Exists newPath then 55 | Directory.SetCurrentDirectory newPath 56 | else 57 | writeError "directory not found" 58 | 59 | /// Creates a new empty directory. 60 | let private mkdir args writeOut writeError = 61 | if List.isEmpty args then 62 | writeError "no directory name speciifed" 63 | else 64 | let path = Path.Combine (currentDir(), args.[0]) 65 | if Directory.Exists path then 66 | writeError "directory already exists" 67 | else 68 | Directory.CreateDirectory path |> ignore 69 | writeOut "directory created" 70 | 71 | /// Deletes a directory, if empty. 72 | let private rmdir args writeOut writeError = 73 | if List.isEmpty args then 74 | writeError "no directory name speciifed" 75 | else 76 | let isForced = args.[0] = "-f" 77 | let path = Path.Combine (currentDir(), args.[if isForced then 1 else 0]) 78 | if not (Directory.Exists path) then 79 | writeError "directory does not exist" 80 | elif not isForced && Directory.GetFiles (path, "*", SearchOption.AllDirectories) |> Array.isEmpty |> not then 81 | writeError "directory was not empty" 82 | else 83 | Directory.Delete (path, isForced) |> ignore 84 | writeOut "directory deleted" 85 | 86 | /// Reads out the content of a file to the output. 87 | let private cat args writeOut writeError = 88 | if List.isEmpty args then 89 | writeError "no file specified" 90 | elif not (File.Exists args.[0]) then 91 | writeError "file not found" 92 | else 93 | writeOut (File.ReadAllText args.[0]) 94 | 95 | /// Copies a file into a new location. 96 | let private cp args writeOut writeError = 97 | if List.length args < 2 then 98 | writeError "wrong number of arguments: please specify source and dest" 99 | else 100 | let isForced = args.[0] = "-f" 101 | let source = Path.Combine(currentDir(), args.[if isForced then 1 else 0]) 102 | if not (File.Exists source) then 103 | writeError "source file path does not exist or is invalid" 104 | else 105 | let dest = Path.Combine(currentDir(), args.[if isForced then 2 else 1]) 106 | let isDir = Directory.Exists dest 107 | let baseDir = Path.GetDirectoryName dest 108 | if not isDir && not (Directory.Exists baseDir) then 109 | writeError "destination directory or file path does not exist or is invalid" 110 | elif not isForced && not isDir && File.Exists dest then 111 | writeError "destination file already exists" 112 | elif not isDir then 113 | if File.Exists dest then File.Delete dest 114 | File.Copy (source, dest) 115 | writeOut "file copied" 116 | else 117 | let fileName = Path.GetFileName source 118 | let dest = Path.Combine(dest, fileName) 119 | if not isForced && File.Exists dest then 120 | writeError "destination file already exists" 121 | else 122 | if File.Exists dest then File.Delete dest 123 | File.Copy (source, dest) 124 | writeOut "file copied" 125 | 126 | /// Moves a file to a new location. 127 | let private mv args writeOut writeError = 128 | if List.length args < 2 then 129 | writeError "wrong number of arguments: please specify source and dest" 130 | else 131 | let isForced = args.[0] = "-f" 132 | let source = Path.Combine(currentDir(), args.[if isForced then 1 else 0]) 133 | if not (File.Exists source) then 134 | writeError "source file path does not exist or is invalid" 135 | else 136 | let dest = Path.Combine(currentDir(), args.[if isForced then 2 else 1]) 137 | let isDir = Directory.Exists dest 138 | let baseDir = Path.GetDirectoryName dest 139 | if not isDir && not (Directory.Exists baseDir) then 140 | writeError "destination directory or file path does not exist or is invalid" 141 | elif not isForced && not isDir && File.Exists dest then 142 | writeError "destination file already exists" 143 | elif not isDir then 144 | if File.Exists dest then File.Delete dest 145 | File.Move (source, dest) 146 | writeOut "file moved" 147 | else 148 | let fileName = Path.GetFileName source 149 | let dest = Path.Combine(dest, fileName) 150 | if not isForced && File.Exists dest then 151 | writeError "destination file already exists" 152 | else 153 | if File.Exists dest then File.Delete dest 154 | File.Move (source, dest) 155 | writeOut "file moved" 156 | 157 | /// Removes a file or directory. 158 | let private rm args writeOut writeError = 159 | if List.length args < 1 then 160 | writeError "no target specified" 161 | else 162 | let isForced = args.[0] = "-f" 163 | let target = Path.Combine(currentDir(), args.[if isForced then 1 else 0]) 164 | if File.Exists target then 165 | File.Delete target 166 | writeOut "file deleted" 167 | else if Directory.Exists target then 168 | if not isForced && not (Array.isEmpty (Directory.GetFiles target)) then 169 | writeError "directory is not empty" 170 | else 171 | Directory.Delete (target, isForced) 172 | writeOut "directory deleted" 173 | else 174 | writeError "file or directory does not exist" 175 | 176 | /// Enumerates all environment variables, or reads/sets a single value. 177 | let private env args writeOut _ = 178 | if List.isEmpty args then 179 | Environment.GetEnvironmentVariables () 180 | |> Seq.cast 181 | |> Seq.map (fun d -> sprintf "%s=%s" (unbox d.Key) (unbox d.Value)) 182 | |> Seq.iter writeOut 183 | else if args.Length = 1 then 184 | writeOut (Environment.GetEnvironmentVariable args.[0]) 185 | else 186 | Environment.SetEnvironmentVariable (args.[0], args.[1]) 187 | 188 | /// This list maps the text entered by the user to the implementation to be run, and the help text for the command. 189 | let builtins = 190 | [ 191 | "clear", (clear, "clears the console") 192 | "echo", (echo, "prints out all text following the echo command to output") 193 | "dir", (dir, "same as ls, will list all files and directories. arguments are [path] [searchPattern], both optional") 194 | "ls", (dir, "same as dir, will list all files and directories. arguments are [path] [searchPattern], both optional") 195 | "cd", (cd, "changes the current directory to the directory specified by the first argument") 196 | "mkdir", (mkdir, "creates a new directory at the position specified by the first argument") 197 | "rmdir", (rmdir, "removes a directory (use 'rmdir -f [dir]' to remove directories containing files)") 198 | "cat", (cat, "prints the contents of the file specified to the output") 199 | "cp", (cp, "copies the source file to the destination folder or filepath (use cp -f [src] [dest] to overwrite)") 200 | "mv", (mv, "moves the source file to the destination folder or filepath (use mv -f [src] [dest] to overwrite)") 201 | "rm", (rm, "same as del, deletes the target file or directory (use rm -f [dir] to delete a directory containing files)") 202 | "del", (rm, "same as rm, deletes the target file or directory (use del -f [dir] to delete a directory containing files)") 203 | "env", (env, "either lists all env vars, reads a specific var, or sets a var to a value") 204 | // The following three special builtins are here so that help can access their content. 205 | // However they have no implementation, as they are invoked by the coreloop and processCommand methods 206 | // in Program.fs rather than via the normal builtin execution process. 207 | "?", ((fun _ _ _ -> ()), "same as help, prints the builtin list, or the help of specific commands") 208 | "help", ((fun _ _ _ -> ()), "same as ?, prints the builtin list, or the help of specific commands") 209 | "exit", ((fun _ _ _ -> ()), "exits FSH") 210 | ] 211 | /// The map is specifically used to match entered text to implementation. 212 | let builtinMap = 213 | builtins 214 | |> List.map (fun (commandName, (implementation, _)) -> commandName, implementation) 215 | |> Map.ofList 216 | 217 | // For the above code, the reason why the map and list is seperate is that a map is ordered by its keys. 218 | // For help, the order of builtin's is important and is preserved in the list. 219 | 220 | /// Help provides helptext for a given builtin (including itself). 221 | /// It is defined after the builtin map, as it needs to read from the map to perform its function. 222 | let help args writeOut _ = 223 | [ 224 | if List.isEmpty args then 225 | yield sprintf "\nThe following builtin commands are supported by FSH:\n" 226 | yield! builtins |> List.map (fun (n, _) -> sprintf "\t%s" n) 227 | yield sprintf "\nFor further info on a command, use help [command name] [command name2] etc, e.g. 'help echo'\n" 228 | else 229 | yield! 230 | args 231 | |> List.choose (fun commandName -> 232 | List.tryFind (fun item -> fst item = commandName) builtins 233 | |> Option.bind (fun (_, (_, helpText)) -> Some (commandName, helpText))) 234 | |> List.map (fun result -> 235 | result ||> sprintf "%s: %s") 236 | ] |> Seq.iter writeOut --------------------------------------------------------------------------------