diff --git a/Fable.sln b/Fable.sln index 22807fb60..72dc19e0c 100644 --- a/Fable.sln +++ b/Fable.sln @@ -72,6 +72,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fable.Library.TypeScript", EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Quicktest.Rust", "src\quicktest-rust\Quicktest.Rust.fsproj", "{0818518D-5BAF-498E-B2E8-C3FA5EC3758D}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fable.Spectre.Cli", "src\Fable.Spectre.Cli\Fable.Spectre.Cli.fsproj", "{4942CD4A-6006-4DB9-A944-5A9236E73313}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -186,6 +188,10 @@ Global {0818518D-5BAF-498E-B2E8-C3FA5EC3758D}.Debug|Any CPU.Build.0 = Debug|Any CPU {0818518D-5BAF-498E-B2E8-C3FA5EC3758D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0818518D-5BAF-498E-B2E8-C3FA5EC3758D}.Release|Any CPU.Build.0 = Release|Any CPU + {4942CD4A-6006-4DB9-A944-5A9236E73313}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4942CD4A-6006-4DB9-A944-5A9236E73313}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4942CD4A-6006-4DB9-A944-5A9236E73313}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4942CD4A-6006-4DB9-A944-5A9236E73313}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -220,6 +226,7 @@ Global {A68CF5C2-DAB1-451F-AA36-3C171C61D4F2} = {493A3959-758E-4D88-8E82-16680E70D282} {C0AD4BF9-638A-43D0-AD46-AAC72C508B42} = {C8CB96CF-68A8-4083-A0F8-319275CF8097} {0818518D-5BAF-498E-B2E8-C3FA5EC3758D} = {C8CB96CF-68A8-4083-A0F8-319275CF8097} + {4942CD4A-6006-4DB9-A944-5A9236E73313} = {C8CB96CF-68A8-4083-A0F8-319275CF8097} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {58DF9285-8523-4EAC-B598-BE5B02A76A00} diff --git a/src/Fable.Spectre.Cli/BuildalyzerCrackerResolver.fs b/src/Fable.Spectre.Cli/BuildalyzerCrackerResolver.fs new file mode 100644 index 000000000..c5eca504c --- /dev/null +++ b/src/Fable.Spectre.Cli/BuildalyzerCrackerResolver.fs @@ -0,0 +1,163 @@ +namespace Fable.Cli + +open System +open System.Xml.Linq +open System.Text.RegularExpressions +open Fable +open Fable.AST +open Fable.Compiler.Util +open Fable.Compiler.ProjectCracker +open Buildalyzer + +/// Use Buildalyzer to invoke MSBuild and get F# compiler args from an .fsproj file. +/// As we'll merge this later with other projects we'll only take the sources and +/// the references, checking if some .dlls correspond to Fable libraries +type BuildalyzerCrackerResolver() = + let mutable manager = None + + let tryGetResult (isMain: bool) (opts: CrackerOptions) (manager: AnalyzerManager) (maybeCsprojFile: string) = + if isMain && not opts.NoRestore then + Process.runSync + (IO.Path.GetDirectoryName opts.ProjFile) + "dotnet" + [ + "restore" + IO.Path.GetFileName maybeCsprojFile + // $"-p:TargetFramework={opts.TargetFramework}" + for constant in opts.FableOptions.Define do + $"-p:{constant}=true" + ] + |> ignore + + let analyzer = manager.GetProject(maybeCsprojFile) + + let env = + analyzer.EnvironmentFactory.GetBuildEnvironment( + Environment.EnvironmentOptions(DesignTime = true, Restore = false) + ) + // If the project targets multiple frameworks, multiple results will be returned + // For now we just take the first one with non-empty command + let results = analyzer.Build(env) + results |> Seq.tryFind (fun r -> String.IsNullOrEmpty(r.Command) |> not) + + interface ProjectCrackerResolver with + member x.GetProjectOptionsFromProjectFile(isMain, options: CrackerOptions, projectFile) = + let manager = + match manager with + | Some m -> m + | None -> + let log = new System.IO.StringWriter() + let amo = AnalyzerManagerOptions(LogWriter = log) + let m = AnalyzerManager(amo) + m.SetGlobalProperty("Configuration", options.Configuration) + // m.SetGlobalProperty("TargetFramework", opts.TargetFramework) + for define in options.FableOptions.Define do + m.SetGlobalProperty(define, "true") + + manager <- Some m + m + + // Because BuildAnalyzer works better with .csproj, we first "dress up" the project as if it were a C# one + // and try to adapt the results. If it doesn't work, we try again to analyze the .fsproj directly + let csprojResult = + let csprojFile = projectFile.Replace(".fsproj", ".fable-temp.csproj") + + if IO.File.Exists(csprojFile) then + None + else + try + IO.File.Copy(projectFile, csprojFile) + + let xmlDocument = XDocument.Load(csprojFile) + + let xmlComment = + XComment( + """This is a temporary file used by Fable to restore dependencies. +If you see this file in your project, you can delete it safely""" + ) + + // An fsproj/csproj should always have a root element + // so it should be safe to add the comment as first child + // of the root element + xmlDocument.Root.AddFirst(xmlComment) + xmlDocument.Save(csprojFile) + + tryGetResult isMain options manager csprojFile + |> Option.map (fun (r: IAnalyzerResult) -> + // Careful, options for .csproj start with / but so do root paths in unix + let reg = Regex(@"^\/[^\/]+?(:?:|$)") + + let comArgs = + r.CompilerArguments + |> Array.map (fun line -> + if reg.IsMatch(line) then + if line.StartsWith("/reference", StringComparison.Ordinal) then + "-r" + line.Substring(10) + else + "--" + line.Substring(1) + else + line + ) + + let comArgs = + match r.Properties.TryGetValue("OtherFlags") with + | false, _ -> comArgs + | true, otherFlags -> + let otherFlags = otherFlags.Split(' ', StringSplitOptions.RemoveEmptyEntries) + + Array.append otherFlags comArgs + + comArgs, r + ) + finally + File.safeDelete csprojFile + // Restore the original fsproj because when restoring/analyzing the + // csproj implicit references added by F# SDKs are not included + // See https://github.com/fable-compiler/Fable/issues/3719 + // Restoring the F# project should restore the bin/obj folders + // to their expected state + let projDir = IO.Path.GetDirectoryName projectFile + + Process.runSync projDir "dotnet" [ "restore"; projectFile ] |> ignore + + let compilerArgs, result = + csprojResult + |> Option.orElseWith (fun () -> + tryGetResult isMain options manager projectFile + |> Option.map (fun r -> + // result.CompilerArguments doesn't seem to work well in Linux + let comArgs = Regex.Split(r.Command, @"\r?\n") + comArgs, r + ) + ) + |> function + | Some result -> result + // TODO: Get Buildalyzer errors from the log + | None -> $"Cannot parse {projectFile}" |> Fable.FableError |> raise + + let projDir = IO.Path.GetDirectoryName(projectFile) + + let projOpts = + compilerArgs + |> Array.skipWhile (fun line -> not (line.StartsWith('-'))) + |> Array.map (fun f -> + if + f.EndsWith(".fs", StringComparison.Ordinal) + || f.EndsWith(".fsi", StringComparison.Ordinal) + then + if Path.IsPathRooted f then + f + else + Path.Combine(projDir, f) + else + f + ) + + let outputType = ReadOnlyDictionary.tryFind "OutputType" result.Properties + + { + ProjectOptions = projOpts + ProjectReferences = Seq.toArray result.ProjectReferences + OutputType = outputType + TargetFramework = Some result.TargetFramework + } diff --git a/src/Fable.Spectre.Cli/Commands/Clean.fs b/src/Fable.Spectre.Cli/Commands/Clean.fs new file mode 100644 index 000000000..37cc58dff --- /dev/null +++ b/src/Fable.Spectre.Cli/Commands/Clean.fs @@ -0,0 +1,96 @@ +module Fable.Spectre.Cli.Commands.Clean + +open System +open Fable +open Fable.Spectre.Cli.Settings.Clean +open Spectre.Console +open Spectre.Console.Cli +open SpectreCoff + +type CleanCommand() = + inherit Command() + + override this.Execute(_, settings) = + let logAlways content = toConsole content + + let logVerbose (content: Lazy) = + if settings.verbosity.IsVerbose then + logAlways content.Value + + let ignoreDirs = set [ "bin"; "obj"; "node_modules" ] + + let outDir = + if settings.cwd |> String.IsNullOrWhiteSpace then + None + else + Some settings.cwd + + let fileExt = settings.extension + + let cleanDir = outDir |> Option.defaultValue settings.cwd |> IO.Path.GetFullPath + + // clean is a potentially destructive operation, we need a permission before proceeding + let payload = [ V "all"; E $"*{fileExt}[.map]"; V "files in"; E cleanDir ] + + if not settings.yes then + Many + [ + V "This will recursively" + MarkupCD(Color.DarkOrange, [ Decoration.Bold ], "delete") + yield! payload + ] + |> toConsole + + if confirm "Continue?" |> not then + V "Clean was cancelled." |> toConsole + exit 0 + else + V "Deleting" :: payload |> Many |> toConsole + + let mutable fileCount = 0 + let mutable fableModulesDeleted = false + + let asyncProcess (_context: StatusContext) = + async { + let rec recClean dir = + seq { + yield! IO.Directory.GetFiles(dir, "*" + fileExt) + yield! IO.Directory.GetFiles(dir, "*" + fileExt + ".map") + } + |> Seq.iter (fun file -> + async { + IO.File.Delete(file) + fileCount <- fileCount + 1 + logVerbose (lazy ("Deleted " + file |> V)) + } + |> Async.RunSynchronously + ) + + IO.Directory.GetDirectories(dir) + |> Array.filter (fun subdir -> ignoreDirs.Contains(IO.Path.GetFileName(subdir)) |> not) + |> Array.iter (fun subdir -> + if IO.Path.GetFileName(subdir) = Naming.fableModules then + IO.Directory.Delete(subdir, true) + fableModulesDeleted <- true + + V $"Deleted {IO.Path.GetRelativePath(settings.cwd, subdir)}" |> logAlways + else + recClean subdir + ) + + recClean cleanDir + } + + Status.start "Cleaning directories" asyncProcess |> Async.RunSynchronously + + + if fileCount = 0 && not fableModulesDeleted then + V + ":orange_circle: No files have been deleted. If Fable output is in another directory, pass it as an argument." + |> logAlways + else + ":check_mark: Clean completed! Files deleted: " + string fileCount + |> V + |> logAlways + + 0 diff --git a/src/Fable.Spectre.Cli/Commands/Compile.fs b/src/Fable.Spectre.Cli/Commands/Compile.fs new file mode 100644 index 000000000..92247ab41 --- /dev/null +++ b/src/Fable.Spectre.Cli/Commands/Compile.fs @@ -0,0 +1,209 @@ +module Fable.Spectre.Cli.Commands.Compile + +open System +open Fable +open Fable.Cli +open Fable.Cli.Main +open Fable.Spectre.Cli.Settings +open Fable.Spectre.Cli.Settings.Compile +open Fable.Spectre.Cli.Settings.Root +open Fable.Spectre.Cli.Settings.Spec +open Fable.Spectre.Cli.Settings.Spec.Output +open Microsoft.Extensions.Logging +open Spectre.Console +open Spectre.Console.Cli +open SpectreCoff +open Fable.Compiler.Util +open CustomLogging + +let private logPrelude (settings: ICliArgs) = + if not settings.version then + Many + [ + MarkupCD(Color.DodgerBlue1, [ Decoration.Bold ], "Fable") + E $"{Literals.VERSION}:" + Dim $"F# to {settings.language} compiler" + match getStatus settings.language with + | "stable" + | "" -> () + | status -> + MarkupD([ Decoration.Dim ], $"(status: {markupString None [ Decoration.Italic ] status})") + |> padLeft 2 + BL + V "Thanks to the contributor!" + E $"@{Contributors.getRandom ()}" + BL + E "Stand with" + MarkupCD(Color.Aqua, [ Decoration.Bold ], "Ukraine!") + Link "https://standwithukraine.com.ua/" + ] + |> customPanel HeaderPanel "" + |> toConsoleInline + + match getLibPkgVersion settings.language with + | Some(repository, pkgName, version) -> + MarkupD([ Decoration.Dim ], $"Minimum {pkgName} version (when installed from {repository}): {version}") + |> toConsole + | None -> () + +let executeCompileCommon (args: ICliArgs) = + let createLogger level = + use factory = + LoggerFactory.Create(fun builder -> + builder.SetMinimumLevel(level).AddCustomConsole(fun options -> options.UseNoPrefixMsgStyle <- true) + |> ignore + ) + + factory.CreateLogger("") + + Compiler.SetLanguageUnsafe args.language + + match args.verbosity with + | Verbosity.Normal -> LogLevel.Information + | Verbosity.Verbose -> LogLevel.Debug + | Verbosity.Silent -> LogLevel.Warning + |> createLogger + |> Log.setLogger args.verbosity + + CompilerOptionsHelper.Make( + language = args.language, + typedArrays = args.typedArrays, + fileExtension = args.extension, + define = args.definitions, + debugMode = (args.configuration = "Debug"), + optimizeFSharpAst = args.optimize, + noReflection = args.noReflection, + verbosity = args.verbosity + ) + +let private createCliArgs + (targetPath: string) + (isPrecompile: bool) + (compileOptionsHelper: CompilerOptions) + (cliArgs: ICliArgs) + = + { + ProjectFile = targetPath + RootDir = cliArgs.workingDirectory + OutDir = cliArgs.outputDirectory + IsWatch = cliArgs.watch + Precompile = isPrecompile + PrecompiledLib = cliArgs.precompiledLib + PrintAst = cliArgs.printAst + FableLibraryPath = cliArgs.fableLib + Configuration = cliArgs.configuration + NoRestore = cliArgs.noRestore + NoCache = cliArgs.noCache + NoParallelTypeCheck = cliArgs.noParallelTypeCheck + SourceMaps = cliArgs.sourceMap + SourceMapsRoot = cliArgs.sourceMapRoot + Exclude = cliArgs.exclude |> Array.toList + Replace = cliArgs.replace + RunProcess = None // TODO + CompilerOptions = compileOptionsHelper + Verbosity = cliArgs.verbosity + } + +let validateTargetFile (args: ICommonArgs) = + let files = + args.workingDirectory |> IO.Directory.EnumerateFileSystemEntries |> Seq.toList + + files + |> List.filter _.EndsWith(".fsproj", StringComparison.Ordinal) + |> function + | [] -> files |> List.filter _.EndsWith(".fsx", StringComparison.Ordinal) + | candidates -> candidates + |> function + | [] -> "Cannot find .fsproj/.fsx in dir: " + args.workingDirectory |> Error + | [ fsproj ] -> Ok fsproj + | _ -> "Found multiple .fsproj/.fsx in dir: " + args.workingDirectory |> Error + +let runCompilation isPrecompiled targetFile cliArgs = + let compilerOptions = cliArgs |> executeCompileCommon + let cliArgs' = createCliArgs targetFile isPrecompiled compilerOptions cliArgs + + let startCompilation () = + State.Create(cliArgs', watchDelay = cliArgs.watchDelay, useMSBuildForCracking = cliArgs.legacyCracker) + |> startCompilationAsync + |> Async.RunSynchronously + + match cliArgs.outputDirectory with + | Some dir when isPrecompiled -> File.withLock dir startCompilation + | _ -> startCompilation () + |> Result.mapEither ignore fst + |> function + | Ok _ -> 0 + | Error msg -> + Log.error msg + 1 + +[] +type CompilingCommand<'T when 'T :> FableSettingsBase>() = + inherit Command<'T>() + member val targetFile: string = "" with get, set + // IMPURE + member private this.ValidateAndSetTargetFile(settings: 'T) = + settings + |> validateTargetFile + |> Result.map (fun targetFile -> this.targetFile <- targetFile) + + override this.Validate(context, settings) = + if not settings.version then + match this.ValidateAndSetTargetFile(settings) with + | Error errorMsg -> ValidationResult.Error(errorMsg) + | _ -> base.Validate(context, settings) + else + base.Validate(context, settings) + +type FableCommand() = + inherit CompilingCommand() + + override this.Execute(_context, settings) = + settings :> ICliArgs |> logPrelude + + if settings.version then + 0 + else + runCompilation false this.targetFile settings + +type WatchCommand() = + inherit CompilingCommand() + + override this.Execute(_context, settings) = + settings :> ICliArgs |> logPrelude + runCompilation false this.targetFile settings + +type CompileCommandBase<'T when 'T :> FableSettingsBase>() = + inherit CompilingCommand<'T>() + + override this.Execute(_context, settings) = + settings :> ICliArgs |> logPrelude + runCompilation false this.targetFile settings + +type CompileWatchCommandBase<'T, 'Base when 'T :> FableSettingsBase and 'Base :> FableSettingsBase>() = + inherit CompilingCommand<'T>() + interface ICommandLimiter<'Base> + + override this.Execute(_context, settings) = + settings :> ICliArgs |> logPrelude + runCompilation false this.targetFile settings + +type FablePrecompileCommand() = + inherit CompilingCommand() + + override this.Execute(_context, settings) = + settings :> ICliArgs |> logPrelude + runCompilation true this.targetFile settings + +type JavaScriptCommand = CompileCommandBase +type TypeScriptCommand = CompileCommandBase +type PythonCommand = CompileCommandBase +type RustCommand = CompileCommandBase +type DartCommand = CompileCommandBase +type PhpCommand = CompileCommandBase +type JavaScriptWatchCommand = CompileWatchCommandBase +type TypeScriptWatchCommand = CompileWatchCommandBase +type PythonWatchCommand = CompileWatchCommandBase +type RustWatchCommand = CompileWatchCommandBase +type DartWatchCommand = CompileWatchCommandBase +type PhpWatchCommand = CompileWatchCommandBase diff --git a/src/Fable.Spectre.Cli/Contributors.fs b/src/Fable.Spectre.Cli/Contributors.fs new file mode 100644 index 000000000..79145a491 --- /dev/null +++ b/src/Fable.Spectre.Cli/Contributors.fs @@ -0,0 +1,131 @@ +module Fable.Cli.Contributors + +let getRandom () = + let contributors = + [| + "zpodlovics" + "zanaptak" + "worldbeater" + "voronoipotato" + "theimowski" + "tforkmann" + "stroborobo" + "simra" + "sasmithjr" + "ritcoder" + "rfrerebe" + "rbauduin" + "mike-morr" + "kirill-gerasimenko" + "kerams" + "justinjstark" + "josselinauguste" + "johannesegger" + "jbeeko" + "iyegoroff" + "intrepion" + "inchingforward" + "hoonzis" + "goswinr" + "fbehrens" + "drk-mtr" + "devcrafting" + "dbrattli" + "damonmcminn" + "ctaggart" + "cmeeren" + "cboudereau" + "byte-666" + "bentayloruk" + "SirUppyPancakes" + "Neftedollar" + "Leonqn" + "Kurren123" + "KevinLamb" + "BillHally" + "2sComplement" + "xtuc" + "vbfox" + "selketjah" + "psfblair" + "pauldorehill" + "mexx" + "matthid" + "irium" + "halfabench" + "easysoft2k15" + "dgchurchill" + "Titaye" + "SCullman" + "MaxWilson" + "JacobChang" + "jmmk" + "eugene-g" + "ericharding" + "enricosada" + "cloudRoutine" + "anchann" + "ThisFunctionalTom" + "0x53A" + "oopbase" + "i-p" + "battermann" + "Nhowka" + "FrankBro" + "tomcl" + "piaste" + "fsoikin" + "scitesy" + "chadunit" + "Pauan" + "xdaDaveShaw" + "ptrelford" + "johlrich" + "7sharp9" + "mastoj" + "coolya" + "valery-vitko" + "Shmew" + "zaaack" + "markek" + "Alxandr" + "Krzysztof-Cieslak" + "davidtme" + "nojaf" + "jgrund" + "tpetricek" + "fdcastel" + "davidpodhola" + "inosik" + "MangelMaxime" + "Zaid-Ajaj" + "forki" + "ncave" + "alfonsogarciacaro" + "do-wa" + "jwosty" + "mlaily" + "delneg" + "GordonBGood" + "Booksbaum" + "NickDarvey" + "thinkbeforecoding" + "cartermp" + "chkn" + "MNie" + "Choc13" + "davedawkins" + "njlr" + "steveofficer" + "cannorin" + "thautwarm" + "hensou" + "IanManske" + "entropitor" + "kant2002" + "johannesmols" + |] + + Array.length contributors + |> System.Random().Next + |> fun i -> Array.item i contributors diff --git a/src/Fable.Spectre.Cli/CustomLogging.fs b/src/Fable.Spectre.Cli/CustomLogging.fs new file mode 100644 index 000000000..5d603750e --- /dev/null +++ b/src/Fable.Spectre.Cli/CustomLogging.fs @@ -0,0 +1,78 @@ +// Source: https://github.com/ionide/FSharp.Analyzers.SDK/blob/main/src/FSharp.Analyzers.Cli/CustomLogging.fs +module Fable.Cli.CustomLogging + +open System +open System.IO +open System.Runtime.CompilerServices +open Microsoft.Extensions.Logging +open Microsoft.Extensions.Logging.Console +open Microsoft.Extensions.Logging.Abstractions +open Microsoft.Extensions.Options + +type CustomOptions() = + inherit ConsoleFormatterOptions() + + /// if true: no LogLevel as prefix, colored output according to LogLevel + /// if false: LogLevel as prefix, no colored output + member val UseNoPrefixMsgStyle = false with get, set + +type CustomFormatter(options: IOptionsMonitor) as this = + inherit ConsoleFormatter("customName") + + let mutable optionsReloadToken: IDisposable = null + let mutable formatterOptions = options.CurrentValue + let origColor = Console.ForegroundColor + + do optionsReloadToken <- options.OnChange(fun x -> this.ReloadLoggerOptions(x)) + + member private _.ReloadLoggerOptions(opts: CustomOptions) = formatterOptions <- opts + + override this.Write<'TState> + (logEntry: inref>, _scopeProvider: IExternalScopeProvider, textWriter: TextWriter) + = + let message = logEntry.Formatter.Invoke(logEntry.State, logEntry.Exception) + + if formatterOptions.UseNoPrefixMsgStyle then + this.SetColor(textWriter, logEntry.LogLevel) + textWriter.WriteLine(message) + this.ResetColor() + else + this.WritePrefix(textWriter, logEntry.LogLevel) + textWriter.WriteLine(message) + + member private _.WritePrefix(textWriter: TextWriter, logLevel: LogLevel) = + match logLevel with + | LogLevel.Trace -> textWriter.Write("trace: ") + | LogLevel.Debug -> textWriter.Write("debug: ") + | LogLevel.Information -> textWriter.Write("info: ") + | LogLevel.Warning -> textWriter.Write("warn: ") + | LogLevel.Error -> textWriter.Write("error: ") + | LogLevel.Critical -> textWriter.Write("critical: ") + | _ -> () + + // see https://learn.microsoft.com/en-us/dotnet/core/extensions/console-log-formatter + member private _.SetColor(textWriter: TextWriter, logLevel: LogLevel) = + + let color = + match logLevel with + | LogLevel.Error -> "\x1B[31m" // ConsoleColor.DarkRed + | LogLevel.Warning -> "\x1B[33m" // ConsoleColor.DarkYellow + | LogLevel.Information -> "\x1B[37m" // ConsoleColor.Gray + | LogLevel.Trace -> "\x1B[1m\x1B[36m" // ConsoleColor.Cyan + | _ -> "\x1B[39m\x1B[22m" // default foreground color + + textWriter.Write(color) + + member private _.ResetColor() = Console.ForegroundColor <- origColor + + interface IDisposable with + member _.Dispose() = optionsReloadToken.Dispose() + +[] +type ConsoleLoggerExtensions = + + [] + static member AddCustomConsole(builder: ILoggingBuilder, configure: Action) : ILoggingBuilder = + builder + .AddConsole(fun options -> options.FormatterName <- "customName") + .AddConsoleFormatter(configure) diff --git a/src/Fable.Spectre.Cli/Entry.fs b/src/Fable.Spectre.Cli/Entry.fs new file mode 100644 index 000000000..6b4285573 --- /dev/null +++ b/src/Fable.Spectre.Cli/Entry.fs @@ -0,0 +1,150 @@ +open Fable +open Fable.Spectre.Cli.Commands.Clean +open Fable.Spectre.Cli.Commands.Compile +open Fable.Spectre.Cli.Settings.Compile +open Fable.Spectre.Cli.Settings.Spec.Output +open Spectre.Console +open Spectre.Console.Cli + +[] +let main argv = + let app = CommandApp() + + app.Configure(fun config -> + config.Settings.ShowOptionDefaultValues <- false + config.Settings.HelpProviderStyles.Options.RequiredOption <- Style(foreground = Color.Blue) + // NOTE: The order the branches are added here determines the order + // in the help printout. We order this by popularity. + config + .AddBranch( + "javascript", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + + branchConfig.SetDescription( + "[dim]Fable for javascript[/] [grey][underline]DEFAULT[/] (alias js)[/]" + ) + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for javascript in watch mode") + |> ignore + ) + ) + .WithAlias("js") + |> ignore + + config + .AddBranch( + "typescript", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + branchConfig.SetDescription("[dim]Fable for typescript[/] [grey](alias ts)[/]") + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for typescript in watch mode") + |> ignore + ) + ) + .WithAlias("ts") + |> ignore + + config + .AddBranch( + "python", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + branchConfig.SetDescription("[dim]Fable for python[/] [grey](alias py)[/]") + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for python in watch mode") + |> ignore + ) + ) + .WithAlias("py") + |> ignore + + config + .AddBranch( + "rust", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + branchConfig.SetDescription("[dim]Fable for rust[/] [grey](alias rs)[/]") + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for rust in watch mode") + |> ignore + ) + ) + .WithAlias("rs") + |> ignore + + config.AddBranch( + "php", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + branchConfig.SetDescription(dim "Fable for php.") + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for php in watch mode") + |> ignore + ) + ) + |> ignore + + config.AddBranch( + "dart", + (fun (branchConfig: IConfigurator) -> + branchConfig.SetDefaultCommand() + branchConfig.SetDescription(dim "Fable for dart.") + + branchConfig + .AddCommand("watch") + .WithAlias("w") + .WithDescription(dim "Fable for dart in watch mode") + |> ignore + ) + ) + |> ignore + + config + .AddCommand("clean") + .WithDescription(dim "Remove fable_modules folders and files with specified extension (default is .fs.js)") + |> ignore + + config + .AddCommand("watch") + .WithAlias("w") + .WithDescription("[dim]Fable in watch mode[/] [grey](alias 'w')[/]") + |> ignore + + config + .AddCommand("precompile") + // TODO - provide documentation + // .WithDescription(dim "") + .IsHidden() + |> ignore + + config.UseStrictParsing() |> ignore + ) + + app + .SetDefaultCommand() + .WithDescription( + """[bold]Fable compiler[/]. + +All flags and options are available, with defaults set for [italic]JavaScript[/]. +Language specific commands will set defaults for file extensions, and/or restrict/add the available flags for that language.""" + ) + |> ignore + + app.Run(argv) diff --git a/src/Fable.Spectre.Cli/Fable.Spectre.Cli.fsproj b/src/Fable.Spectre.Cli/Fable.Spectre.Cli.fsproj new file mode 100644 index 000000000..442f119a2 --- /dev/null +++ b/src/Fable.Spectre.Cli/Fable.Spectre.Cli.fsproj @@ -0,0 +1,48 @@ + + + + true + Exe + net8.0 + Major + false + true + F# transpiler + $(OtherFlags) --nowarn:3536 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Fable.Spectre.Cli/FileWatchers.fs b/src/Fable.Spectre.Cli/FileWatchers.fs new file mode 100644 index 000000000..58d971054 --- /dev/null +++ b/src/Fable.Spectre.Cli/FileWatchers.fs @@ -0,0 +1,364 @@ +// Ported from C# to F# +// from https://github.com/dotnet/aspnetcore/blob/5fd4f879779b131bf841c95d2a783d7f7742d2fa/src/Tools/dotnet-watch/src/Internal/FileWatcher/PollingFileWatcher.cs +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +module Fable.Cli.FileWatcher + +open System +open System.IO +open System.Runtime.CompilerServices +open System.Threading +open System.Collections.Generic +open System.Diagnostics +open System.Text.RegularExpressions +open Fable.Compiler.Globbing + +type IFileSystemWatcher = + inherit IDisposable + + [] + abstract OnFileChange: IEvent + + [] + abstract OnError: IEvent + + /// Directory path + abstract BasePath: string with get, set + abstract EnableRaisingEvents: bool with get, set + /// File name filters + abstract GlobFilters: string list + +[] +type private FileMeta = + { + FileInfo: FileSystemInfo + FoundAgain: bool + } + +/// An alternative file watcher based on polling. +/// ignoredDirectoryNameRegexes allows ignoring directories to improve performance. +type PollingFileWatcher(watchedDirectoryPath, ignoredDirectoryNameRegexes) = + // The minimum interval to rerun the scan + let minRunInternal = TimeSpan.FromSeconds(0.5) + + let watchedDirectory = new DirectoryInfo(watchedDirectoryPath) + + let mutable knownEntities = new Dictionary() + let mutable tempDictionary = new Dictionary() + let changes = new HashSet() + let mutable knownEntitiesCount = 0 + + let onFileChange = new Event() + let mutable raiseEvents = false + let mutable disposed = false + + let compiledIgnoredDirNames = + ignoredDirectoryNameRegexes + |> Seq.map (fun (regex: string) -> + Regex("^" + regex + "$", RegexOptions.Compiled ||| RegexOptions.CultureInvariant) + ) + |> Array.ofSeq + + let notifyChanges () = + for path in changes do + if not disposed && raiseEvents then + onFileChange.Trigger(path) + + let isIgnored (dirInfo: DirectoryInfo) = + compiledIgnoredDirNames + |> Array.exists (fun regex -> regex.IsMatch(dirInfo.Name)) + + let rec foreachEntityInDirectory (dirInfo: DirectoryInfo) fileAction = + if dirInfo.Exists && not (isIgnored dirInfo) then + let entities = + // If the directory is deleted after the exists check + // this will throw and could crash the process + try + Some(dirInfo.EnumerateFileSystemInfos()) + with :? DirectoryNotFoundException -> + None + + if Option.isSome entities then + for entity in entities.Value do + fileAction entity + + match entity with + | :? DirectoryInfo as subdirInfo -> foreachEntityInDirectory subdirInfo fileAction + | _ -> () + + let rec recordChange (fileInfo: FileSystemInfo) = + if + not (isNull fileInfo) + && not (changes.Contains(fileInfo.Name)) + && not (fileInfo.FullName.Equals(watchedDirectory.FullName, StringComparison.Ordinal)) + then + changes.Add(fileInfo.FullName) |> ignore + + if fileInfo.FullName <> watchedDirectory.FullName then + match fileInfo with + | :? FileInfo as file -> recordChange (file.Directory) + | :? DirectoryInfo as dir -> recordChange (dir.Parent) + | _ -> () + + let checkForChangedFiles () = + changes.Clear() + + foreachEntityInDirectory + watchedDirectory + (fun f -> + let fullFilePath = f.FullName + + match knownEntities.TryGetValue fullFilePath with + | false, _ -> recordChange f // New file + | true, fileMeta -> + + try + if fileMeta.FileInfo.LastWriteTime <> f.LastWriteTime then + recordChange f // File changed + + knownEntities.[fullFilePath] <- { fileMeta with FoundAgain = true } + with :? FileNotFoundException -> + knownEntities.[fullFilePath] <- { fileMeta with FoundAgain = false } + // TryAdd instead of Add because sometimes we get duplicates (?!) + // (Saw multiple times on Linux. Not sure where it came from...) + tempDictionary.TryAdd( + f.FullName, + { + FileInfo = f + FoundAgain = false + } + ) + |> ignore + ) + + for file in knownEntities do + if not (file.Value.FoundAgain) then + recordChange (file.Value.FileInfo) // File deleted + + notifyChanges () + + // Swap the two dictionaries + let swap = knownEntities + knownEntities <- tempDictionary + tempDictionary <- swap + + tempDictionary.Clear() + + let createKnownFilesSnapshot () = + knownEntities.Clear() + + foreachEntityInDirectory + watchedDirectory + (fun f -> + knownEntities.Add( + f.FullName, + { + FileInfo = f + FoundAgain = false + } + ) + ) + + Volatile.Write(&knownEntitiesCount, knownEntities.Count) + + let pollingLoop () = + let stopWatch = Stopwatch.StartNew() + stopWatch.Start() // Present in the C# code but it looks like an oversight + + while not disposed do + // Don't run too often + // The min wait time here can be double + // the value of the variable (FYI) + if stopWatch.Elapsed < minRunInternal then + Thread.Sleep(minRunInternal) + + stopWatch.Reset() + + if raiseEvents then + checkForChangedFiles () + + stopWatch.Stop() + + do + let pollingThread = new Thread(new ThreadStart(pollingLoop)) + pollingThread.IsBackground <- true + pollingThread.Name <- nameof PollingFileWatcher + + createKnownFilesSnapshot () + + pollingThread.Start() + + [] + member this.OnFileChange = onFileChange.Publish + + member this.BasePath = watchedDirectory.FullName + member this.KnownEntitiesCount = Volatile.Read(&knownEntitiesCount) + + /// Defaults to false. Must be set to true to start raising events. + member this.EnableRaisingEvents + with get () = raiseEvents + and set (value) = + if disposed then + raise (ObjectDisposedException(nameof PollingFileWatcher)) + else + raiseEvents <- value + + interface IDisposable with + member this.Dispose() = + if not disposed then + this.EnableRaisingEvents <- false + + disposed <- true + +type private WatcherInstance = + { + Watcher: PollingFileWatcher + FileChangeSubscription: IDisposable + } + +/// A wrapper around the immutable polling watcher, +/// implementing IFileSystemWatcher with its mutable BasePath. +type ResetablePollingFileWatcher(fileNameGlobFilters, ignoredDirectoryNameRegexes) = + let mutable disposed = false + let resetLocker = new obj () + + let onFileChange = new Event() + /// Currently only used to publish the unused interface event + let onError = new Event() + + /// Dispose previous, and return a new instance + let createInstance basePath (previous: WatcherInstance option) = + // Creating a new instance before stopping the previous one + // might return duplicate changes, but at least we should not be losing any. + let newInstance = new PollingFileWatcher(basePath, ignoredDirectoryNameRegexes) + + let previousEnableRaisingEvents = + match previous with + | Some instance -> + let previousEnableRaisingEvents = instance.Watcher.EnableRaisingEvents + + (instance.Watcher :> IDisposable).Dispose() + instance.FileChangeSubscription.Dispose() + previousEnableRaisingEvents + | None -> false // Defaults to EnableRaisingEvents = false to be consistent + + let watcherChangeHandler e = + let name = Path.GetFileName(e: string) // Should also work for directories + + let matchesFilter = + List.isEmpty fileNameGlobFilters + || fileNameGlobFilters |> List.exists (fun filter -> Glob.isMatch filter name) + + if matchesFilter then + onFileChange.Trigger(e) + + newInstance.EnableRaisingEvents <- previousEnableRaisingEvents + + { + Watcher = newInstance + FileChangeSubscription = newInstance.OnFileChange.Subscribe(watcherChangeHandler) + } + + /// Should always be used under lock + let mutable current = None + + interface IFileSystemWatcher with + [] + member this.OnFileChange = onFileChange.Publish + + /// Currently unused for this implementation + [] + member this.OnError = onError.Publish + + member this.BasePath + with get () = + lock + resetLocker + (fun () -> current |> Option.map (fun x -> x.Watcher.BasePath) |> Option.defaultValue "") + and set (value) = + lock + resetLocker + (fun () -> + // Compare normalized paths before recreating the watcher: + if + current.IsNone + || String.IsNullOrWhiteSpace(current.Value.Watcher.BasePath) + || Path.GetFullPath(current.Value.Watcher.BasePath) <> Path.GetFullPath(value) + then + current <- Some(createInstance value current) + ) + + member this.EnableRaisingEvents + with get () = + lock + resetLocker + (fun () -> + current + |> Option.map (fun x -> x.Watcher.EnableRaisingEvents) + |> Option.defaultValue false + ) + and set (value) = + lock + resetLocker + (fun () -> + if current.IsSome then + current.Value.Watcher.EnableRaisingEvents <- value + ) + + member this.GlobFilters = fileNameGlobFilters + + member this.Dispose() = + lock + resetLocker + (fun () -> + if current.IsSome then + (current.Value.Watcher :> IDisposable).Dispose() + current.Value.FileChangeSubscription.Dispose() + disposed <- true + ) + +/// A FileSystemWatcher wrapper that implements the IFileSystemWatcher interface. +type DotnetFileWatcher(globFilters: string list) = + let fileSystemWatcher = new FileSystemWatcher() + + let onFileChange = new Event() + let onError = new Event() + + do + for filter in globFilters do + fileSystemWatcher.Filters.Add(filter) + + let watcherChangeHandler (e: FileSystemEventArgs) = onFileChange.Trigger(e.FullPath) + + let watcherRenameHandler (e: RenamedEventArgs) = + onFileChange.Trigger(e.OldFullPath) + onFileChange.Trigger(e.FullPath) + + let watcherErrorHandler e = onError.Trigger(e) + + fileSystemWatcher.Created.Subscribe(watcherChangeHandler) |> ignore + fileSystemWatcher.Deleted.Subscribe(watcherChangeHandler) |> ignore + fileSystemWatcher.Changed.Subscribe(watcherChangeHandler) |> ignore + fileSystemWatcher.Renamed.Subscribe(watcherRenameHandler) |> ignore + fileSystemWatcher.Error.Subscribe(watcherErrorHandler) |> ignore + + fileSystemWatcher.IncludeSubdirectories <- true + + interface IFileSystemWatcher with + [] + member this.OnFileChange = onFileChange.Publish + + [] + member this.OnError = onError.Publish + + member this.BasePath + with get () = fileSystemWatcher.Path + and set (value) = fileSystemWatcher.Path <- value + + member this.EnableRaisingEvents + with get () = fileSystemWatcher.EnableRaisingEvents + and set (value) = fileSystemWatcher.EnableRaisingEvents <- value + + member this.GlobFilters = fileSystemWatcher.Filters |> List.ofSeq + member this.Dispose() = fileSystemWatcher.Dispose() diff --git a/src/Fable.Spectre.Cli/Main.fs b/src/Fable.Spectre.Cli/Main.fs new file mode 100644 index 000000000..299a36d9f --- /dev/null +++ b/src/Fable.Spectre.Cli/Main.fs @@ -0,0 +1,1365 @@ +module Fable.Cli.Main + +open System +open System.Collections.Concurrent +open System.Collections.Generic +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics +open FSharp.Compiler.SourceCodeServices +open FSharp.Compiler.Symbols + +open Fable +open Fable.AST +open Fable.Transforms +open Fable.Transforms.State +open Fable.Compiler.ProjectCracker +open Fable.Compiler.Util + +module private Util = + type PathResolver with + + static member Dummy = + { new PathResolver with + member _.TryPrecompiledOutPath(_sourceDir, _relativePath) = None + + member _.GetOrAddDeduplicateTargetDir(_importDir, _addTargetDir) = "" + } + + let isImplementationFile (fileName: string) = + fileName.EndsWith(".fs", StringComparison.Ordinal) + || fileName.EndsWith(".fsx", StringComparison.Ordinal) + + let caseInsensitiveSet (items: string seq) : ISet = + let s = HashSet(items) + + for i in items do + s.Add(i) |> ignore + + s :> _ + + let splitVersion (version: string) = + match Version.TryParse(version) with + | true, v -> v.Major, v.Minor, v.Revision + | _ -> 0, 0, 0 + + // let checkFableCoreVersion (checkedProject: FSharpCheckProjectResults) = + // for ref in checkedProject.ProjectContext.GetReferencedAssemblies() do + // if ref.SimpleName = "Fable.Core" then + // let version = System.Text.RegularExpressions.Regex.Match(ref.QualifiedName, @"Version=(\d+\.\d+\.\d+)") + // let expectedMajor, expectedMinor, _ = splitVersion Literals.CORE_VERSION + // let actualMajor, actualMinor, _ = splitVersion version.Groups.[1].Value + // if not(actualMajor = expectedMajor && actualMinor = expectedMinor) then + // failwithf "Fable.Core v%i.%i detected, expecting v%i.%i" actualMajor actualMinor expectedMajor expectedMinor + // // else printfn "Fable.Core version matches" + + let formatException file ex = + let rec innerStack (ex: Exception) = + if isNull ex.InnerException then + ex.StackTrace + else + innerStack ex.InnerException + + let stack = innerStack ex + $"[ERROR] %s{file}{Log.newLine}%s{ex.Message}{Log.newLine}%s{stack}" + + let formatLog rootDir (log: LogEntry) = + match log.FileName with + | None -> log.Message + | Some file -> + // Add ./ to make sure VS Code terminal recognises this as a clickable path + let file = + "." + + IO.Path.DirectorySeparatorChar.ToString() + + IO.Path.GetRelativePath(rootDir, file) + + let severity = + match log.Severity with + | Severity.Warning -> "warning" + | Severity.Error -> "error" + | Severity.Info -> "info" + + match log.Range with + | Some r -> + $"%s{file}(%i{r.start.line},%i{r.start.column}): (%i{r.``end``.line},%i{r.``end``.column}) %s{severity} %s{log.Tag}: %s{log.Message}" + | None -> $"%s{file}(1,1): %s{severity} %s{log.Tag}: %s{log.Message}" + + let logErrors rootDir (logs: LogEntry seq) = + logs + |> Seq.filter (fun log -> log.Severity = Severity.Error) + |> Seq.iter (fun log -> Log.error (formatLog rootDir log)) + + let getFSharpDiagnostics (diagnostics: FSharpDiagnostic array) = + diagnostics + |> Array.map (fun er -> + let severity = + match er.Severity with + | FSharpDiagnosticSeverity.Hidden + | FSharpDiagnosticSeverity.Info -> Severity.Info + | FSharpDiagnosticSeverity.Warning -> Severity.Warning + | FSharpDiagnosticSeverity.Error -> Severity.Error + + let range = + SourceLocation.Create( + start = + { + line = er.StartLine + column = er.StartColumn + 1 + }, + ``end`` = + { + line = er.EndLine + column = er.EndColumn + 1 + } + ) + + let msg = $"%s{er.Message} (code %i{er.ErrorNumber})" + + LogEntry.Make(severity, msg, fileName = er.FileName, range = range, tag = "FSHARP") + ) + + let getOutPath (cliArgs: CliArgs) pathResolver file = + match cliArgs.CompilerOptions.Language with + // For Python we must have an outDir since all compiled files must be inside the same subdir, so if `outDir` is not + // set we set `outDir` to the directory of the project file being compiled. + | Python -> + let fileExt = cliArgs.CompilerOptions.FileExtension + let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile + + let outDir = + match cliArgs.OutDir with + | Some outDir -> outDir + | None -> IO.Path.GetDirectoryName cliArgs.ProjectFile + + let absPath = + let absPath = Imports.getTargetAbsolutePath pathResolver file projDir outDir + + let fileName = IO.Path.GetFileName(file) + + let modules = + absPath + .Substring(outDir.Length, absPath.Length - outDir.Length - fileName.Length) + .Trim([| '/'; '\\' |]) + .Split([| '/'; '\\' |]) + |> Array.map (fun m -> + match m with + | "." -> "" + | m -> m.Replace(".", "_") + ) + |> IO.Path.Join + + let fileName = fileName |> Pipeline.Python.getTargetPath cliArgs + + IO.Path.Join(outDir, modules, fileName) + + Path.ChangeExtension(absPath, fileExt) + | lang -> + let changeExtension path fileExt = + match lang with + | JavaScript + | TypeScript -> + let isInFableModules = Naming.isInFableModules file + + File.changeExtensionButUseDefaultExtensionInFableModules lang isInFableModules path fileExt + | _ -> Path.ChangeExtension(path, fileExt) + + let fileExt = cliArgs.CompilerOptions.FileExtension + + match cliArgs.OutDir with + | Some outDir -> + let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile + + let absPath = Imports.getTargetAbsolutePath pathResolver file projDir outDir + + changeExtension absPath fileExt + | None -> changeExtension file fileExt + + let compileFile (com: CompilerImpl) (cliArgs: CliArgs) pathResolver isSilent = + async { + let fileName = (com :> Compiler).CurrentFile + + try + let outPath = getOutPath cliArgs pathResolver fileName + + // ensure directory exists + let dir = IO.Path.GetDirectoryName outPath + + if not (IO.Directory.Exists dir) then + IO.Directory.CreateDirectory dir |> ignore + + do! Pipeline.compileFile com cliArgs pathResolver isSilent outPath + + return + Ok + {| + File = fileName + OutPath = outPath + Logs = com.Logs + InlineExprs = Array.empty + WatchDependencies = com.WatchDependencies + |} + with e -> + return + Error + {| + File = fileName + Exception = e + |} + } + +module FileWatcherUtil = + let getCommonBaseDir (files: string list) = + let withTrailingSep d = + $"%s{d}%c{IO.Path.DirectorySeparatorChar}" + + files + // FCS may add files in temporary dirs to resolve nuget references in scripts + // See https://github.com/fable-compiler/Fable/pull/2725#issuecomment-1015123642 + |> List.filter (fun file -> + not ( + file.EndsWith(".fsproj.fsx", StringComparison.Ordinal) + // It looks like latest F# compiler puts generated files for resolution of packages + // in scripts in $HOME/.packagemanagement. See #3222 + || file.Contains(".packagemanagement") + ) + ) + |> List.map IO.Path.GetDirectoryName + |> List.distinct + |> List.sortBy (fun f -> f.Length) + |> function + | [] -> failwith "Empty list passed to watcher" + | [ dir ] -> dir + | dir :: restDirs -> + let rec getCommonDir (dir: string) = + // it's important to include a trailing separator when comparing, otherwise things + // like ["a/b"; "a/b.c"] won't get handled right + // https://github.com/fable-compiler/Fable/issues/2332 + let dir' = withTrailingSep dir + + if + restDirs + |> List.forall (fun d -> (withTrailingSep d).StartsWith(dir', StringComparison.Ordinal)) + then + dir + else + match IO.Path.GetDirectoryName(dir) with // 'dir' is empty string ? + | null -> + let badPaths = + restDirs + |> List.filter (fun d -> + not ((withTrailingSep d).StartsWith(dir', StringComparison.Ordinal)) + ) + |> List.truncate 20 + + [ + "Fable is trying to find a common base directory for all files and projects referenced." + $"But '%s{dir'}' is not a common base directory for these source paths:" + for d in badPaths do + $" - {d}" + "If you think this is a bug, please run again with --verbose option and report." + ] + |> String.concat Environment.NewLine + |> failwith + + | dir -> getCommonDir dir + + getCommonDir dir + +open Util +open FileWatcher +open FileWatcherUtil + +type FsWatcher(delayMs: int) = + let globFilters = [ "*.fs"; "*.fsi"; "*.fsx"; "*.fsproj" ] + + let createWatcher () = + let usePolling = + // This is the same variable used by dotnet watch + let envVar = Environment.GetEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER") + + not (isNull envVar) + && (envVar.Equals("1", StringComparison.OrdinalIgnoreCase) + || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)) + + let watcher: IFileSystemWatcher = + if usePolling then + Log.always ("Using polling watcher.") + // Ignored for performance reasons: + let ignoredDirectoryNameRegexes = + [ "(?i)node_modules"; "(?i)bin"; "(?i)obj"; "\..+" ] + + upcast new ResetablePollingFileWatcher(globFilters, ignoredDirectoryNameRegexes) + else + upcast new DotnetFileWatcher(globFilters) + + watcher + + let watcher = createWatcher () + + let observable = + Observable.SingleObservable(fun () -> watcher.EnableRaisingEvents <- false) + + do + watcher.OnFileChange.Add(fun path -> observable.Trigger(path)) + + watcher.OnError.Add(fun ev -> Log.verbose (lazy $"[WATCHER] {ev.GetException().Message}")) + + member _.BasePath = watcher.BasePath + + member _.Observe(filesToWatch: string list) = + let commonBaseDir = getCommonBaseDir filesToWatch + + // It may happen we get the same path with different case in case-insensitive file systems + // https://github.com/fable-compiler/Fable/issues/2277#issuecomment-737748220 + let filePaths = caseInsensitiveSet filesToWatch + watcher.BasePath <- commonBaseDir + watcher.EnableRaisingEvents <- true + + observable + |> Observable.choose (fun fullPath -> + let fullPath = Path.normalizePath fullPath + + if filePaths.Contains(fullPath) then + Some fullPath + else + None + ) + |> Observable.throttle delayMs + |> Observable.map caseInsensitiveSet + +type ProjectCracked(cliArgs: CliArgs, crackerResponse: CrackerResponse, sourceFiles: Fable.Compiler.File array) = + + member _.CliArgs = cliArgs + member _.ProjectFile = cliArgs.ProjectFile + member _.FableOptions = cliArgs.CompilerOptions + member _.ProjectOptions = crackerResponse.ProjectOptions + member _.References = crackerResponse.References + member _.PrecompiledInfo = crackerResponse.PrecompiledInfo + member _.CanReuseCompiledFiles = crackerResponse.CanReuseCompiledFiles + member _.SourceFiles = sourceFiles + + member _.SourceFilePaths = sourceFiles |> Array.map (fun f -> f.NormalizedFullPath) + + member _.FableLibDir = crackerResponse.FableLibDir + member _.FableModulesDir = crackerResponse.FableModulesDir + member _.TreatmentWarningsAsErrors = crackerResponse.TreatWarningsAsErrors + + member _.MakeCompiler(currentFile, project, ?triggeredByDependency) = + let opts = + match triggeredByDependency with + | Some t -> { cliArgs.CompilerOptions with TriggeredByDependency = t } + | None -> cliArgs.CompilerOptions + + let fableLibDir = Path.getRelativePath currentFile crackerResponse.FableLibDir + + let watchDependencies = + if cliArgs.IsWatch then + Some(HashSet()) + else + None + + CompilerImpl( + currentFile, + project, + opts, + fableLibDir, + crackerResponse.OutputType, + ?outDir = cliArgs.OutDir, + ?watchDependencies = watchDependencies + ) + + member _.MapSourceFiles(f) = + ProjectCracked(cliArgs, crackerResponse, Array.map f sourceFiles) + + static member Init(cliArgs: CliArgs, useMSBuildForCracking, ?evaluateOnly: bool) = + let evaluateOnly = defaultArg evaluateOnly false + Log.always $"Parsing {cliArgs.ProjectFileAsRelativePath}..." + + let result, ms = + Performance.measure + <| fun () -> + let resolver: ProjectCrackerResolver = + if useMSBuildForCracking then + Fable.Compiler.MSBuildCrackerResolver() + else + BuildalyzerCrackerResolver() + + CrackerOptions(cliArgs, evaluateOnly) |> getFullProjectOpts resolver + + // We display "parsed" because "cracked" may not be understood by users + Log.always + $"Project and references ({result.ProjectOptions.SourceFiles.Length} source files) parsed in %i{ms}ms{Log.newLine}" + + Log.verbose ( + lazy + $"""F# PROJECT: %s{cliArgs.ProjectFileAsRelativePath} +FABLE LIBRARY: {result.FableLibDir} +TARGET FRAMEWORK: {result.TargetFramework} +OUTPUT TYPE: {result.OutputType} + + %s{result.ProjectOptions.OtherOptions |> String.concat $"{Log.newLine} "} + %s{result.ProjectOptions.SourceFiles |> String.concat $"{Log.newLine} "}{Log.newLine}""" + ) + + // If targeting Python, make sure users are not compiling the project as library by mistake + // (imports won't work when running the code) + match cliArgs.CompilerOptions.Language, result.OutputType with + | Python, OutputType.Library -> + Log.always + "Compiling project as Library. If you intend to run the code directly, please set OutputType to Exe." + | _ -> () + + let sourceFiles = result.ProjectOptions.SourceFiles |> Array.map Fable.Compiler.File + + ProjectCracked(cliArgs, result, sourceFiles) + +type FableCompileResult = + Result< + {| + File: string + OutPath: string + Logs: LogEntry[] + InlineExprs: (string * InlineExpr)[] + WatchDependencies: string[] + |}, + {| + File: string + Exception: exn + |} + > + +type ReplyChannel = AsyncReplyChannel> + +type FableCompilerMsg = + | GetFableProject of replyChannel: AsyncReplyChannel + | StartCompilation of + sourceFiles: Fable.Compiler.File[] * + filesToCompile: string[] * + pathResolver: PathResolver * + isSilent: bool * + isTriggeredByDependency: (string -> bool) * + ReplyChannel + | FSharpFileTypeChecked of FSharpImplementationFileContents + | FSharpCompilationFinished of FSharpCheckProjectResults + | FableFileCompiled of string * FableCompileResult + | UnexpectedError of exn + +type FableCompilerState = + { + FableProj: Project + PathResolver: PathResolver + IsSilent: bool + TriggeredByDependency: string -> bool + FilesToCompile: string[] + FilesToCompileSet: Set + FilesCheckedButNotCompiled: Set + FableFilesToCompileExpectedCount: int + FableFilesCompiledCount: int + FSharpLogs: LogEntry[] + FableResults: FableCompileResult list + HasFSharpCompilationFinished: bool + ReplyChannel: ReplyChannel option + } + + static member Create + (fableProj, filesToCompile: string[], ?pathResolver, ?isSilent, ?triggeredByDependency, ?replyChannel) + = + { + FableProj = fableProj + PathResolver = defaultArg pathResolver PathResolver.Dummy + IsSilent = defaultArg isSilent false + TriggeredByDependency = defaultArg triggeredByDependency (fun _ -> false) + FilesToCompile = filesToCompile + FilesToCompileSet = set filesToCompile + FilesCheckedButNotCompiled = Set.empty + FableFilesToCompileExpectedCount = filesToCompile |> Array.filter isImplementationFile |> Array.length + FableFilesCompiledCount = 0 + FSharpLogs = [||] + FableResults = [] + HasFSharpCompilationFinished = false + ReplyChannel = replyChannel + } + +and FableCompiler(checker: InteractiveChecker, projCracked: ProjectCracked, fableProj: Project) = + let agent = + MailboxProcessor.Start(fun agent -> + let postTo toMsg work = + async { + try + let! result = work () + toMsg result |> agent.Post + with e -> + UnexpectedError e |> agent.Post + } + + let startInThreadPool toMsg work = postTo toMsg work |> Async.Start + + let fableCompile state fileName = + let fableProj = state.FableProj + + startInThreadPool + FableFileCompiled + (fun () -> + async { + let com = + projCracked.MakeCompiler( + fileName, + fableProj, + triggeredByDependency = state.TriggeredByDependency(fileName) + ) + + let! res = compileFile com projCracked.CliArgs state.PathResolver state.IsSilent + + let res = + if not projCracked.CliArgs.Precompile then + res + else + res + |> Result.map (fun res -> + {| res with InlineExprs = fableProj.GetFileInlineExprs(com) |} + ) + + return fileName, res + } + ) + + { state with FilesCheckedButNotCompiled = Set.add fileName state.FilesCheckedButNotCompiled } + + let rec loop state = + async { + match! agent.Receive() with + | UnexpectedError er -> state.ReplyChannel |> Option.iter (fun ch -> Error er |> ch.Reply) + + | GetFableProject channel -> + channel.Reply(state.FableProj) + return! loop state + + | StartCompilation(sourceFiles, + filesToCompile, + pathResolver, + isSilent, + isTriggeredByDependency, + replyChannel) -> + let state = + FableCompilerState.Create( + state.FableProj, + filesToCompile, + pathResolver, + isSilent, + isTriggeredByDependency, + replyChannel + ) + + startInThreadPool + FSharpCompilationFinished + (fun () -> + let filePaths, sourceReader = Fable.Compiler.File.MakeSourceReader sourceFiles + + let subscriber = + if projCracked.CliArgs.NoParallelTypeCheck then + None + else + Some(FSharpFileTypeChecked >> agent.Post) + + checker.ParseAndCheckProject( + projCracked.ProjectFile, + filePaths, + sourceReader, + Array.last filesToCompile, + ?subscriber = subscriber + ) + ) + + return! loop state + + | FSharpFileTypeChecked file -> + // It seems when there's a pair .fsi/.fs the F# compiler gives the .fsi extension to the implementation file + let fileName = file.FileName |> Path.normalizePath |> Path.ensureFsExtension + + // For Rust, delay last file's compilation so other files can finish compiling + if + projCracked.CliArgs.CompilerOptions.Language = Rust + && fileName = Array.last state.FilesToCompile + && state.FableFilesCompiledCount < state.FableFilesToCompileExpectedCount - 1 + then + do! Async.Sleep(1000) + + Log.verbose ( + lazy $"Type checked: {IO.Path.GetRelativePath(projCracked.CliArgs.RootDir, file.FileName)}" + ) + + // Print F# AST to file + if projCracked.CliArgs.PrintAst then + let outPath = getOutPath projCracked.CliArgs state.PathResolver file.FileName + let outDir = IO.Path.GetDirectoryName(outPath) + Printers.printAst outDir [ file ] + + let state = + if not (state.FilesToCompileSet.Contains(fileName)) then + state + else + let state = { state with FableProj = state.FableProj.Update([ file ]) } + fableCompile state fileName + + return! loop state + + | FSharpCompilationFinished results -> + Log.verbose (lazy "Type check finished") + + let state = + { state with + FSharpLogs = getFSharpDiagnostics results.Diagnostics + HasFSharpCompilationFinished = true + } + + let state = + if projCracked.CliArgs.NoParallelTypeCheck then + let implFiles = + if projCracked.CliArgs.CompilerOptions.OptimizeFSharpAst then + results.GetOptimizedAssemblyContents().ImplementationFiles + else + results.AssemblyContents.ImplementationFiles + + let state = { state with FableProj = state.FableProj.Update(implFiles) } + + let filesToCompile = + state.FilesToCompile + |> Array.filter (fun file -> + file.EndsWith(".fs", StringComparison.Ordinal) + || file.EndsWith(".fsx", StringComparison.Ordinal) + ) + + (state, filesToCompile) ||> Array.fold fableCompile + else + state + + FableCompiler.CheckIfCompilationIsFinished(state) + return! loop state + + | FableFileCompiled(fileName, result) -> + let state = + { state with + FableResults = result :: state.FableResults + FableFilesCompiledCount = state.FableFilesCompiledCount + 1 + FilesCheckedButNotCompiled = Set.remove fileName state.FilesCheckedButNotCompiled + } + + if not state.IsSilent then + let msg = + let fileName = IO.Path.GetRelativePath(projCracked.CliArgs.RootDir, fileName) + + $"Compiled {state.FableFilesCompiledCount}/{state.FableFilesToCompileExpectedCount}: {fileName}" + + if projCracked.CliArgs.NoParallelTypeCheck then + Log.always msg + else + let isCi = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) |> not + + match projCracked.CliArgs.Verbosity with + | Verbosity.Silent -> () + | Verbosity.Verbose -> Console.Out.WriteLine(msg) + | Verbosity.Normal -> + // Avoid log pollution in CI. Also, if output is redirected don't try to rewrite + // the same line as it seems to cause problems, see #2727 + if not isCi && not Console.IsOutputRedirected then + // If the message is longer than the terminal width it will jump to next line + let msg = + if msg.Length > 80 then + msg.[..80] + "..." + else + msg + + let curCursorLeft = Console.CursorLeft + + Console.SetCursorPosition(0, Console.CursorTop) + + Console.Out.Write(msg) + let diff = curCursorLeft - msg.Length + + if diff > 0 then + Console.Out.Write(String.replicate diff " ") + + Console.SetCursorPosition(msg.Length, Console.CursorTop) + + FableCompiler.CheckIfCompilationIsFinished(state) + return! loop state + } + + FableCompilerState.Create(fableProj, [||]) |> loop + ) + + member _.GetFableProject() = + agent.PostAndAsyncReply(GetFableProject) + + member _.StartCompilation(sourceFiles, filesToCompile, pathResolver, isSilent, isTriggeredByDependency) = + async { + if Array.isEmpty filesToCompile then + return [||], [] + else + if not isSilent then + Log.always "Started Fable compilation..." + + let! results, ms = + Performance.measureAsync + <| fun () -> + agent.PostAndAsyncReply(fun channel -> + StartCompilation( + sourceFiles, + filesToCompile, + pathResolver, + isSilent, + isTriggeredByDependency, + channel + ) + ) + + return + match results with + | Ok results -> + Log.always $"{Log.newLine}Fable compilation finished in %i{ms}ms{Log.newLine}" + + results + | Error e -> e.Message + Log.newLine + e.StackTrace |> Fable.FableError |> raise + } + + static member CheckIfCompilationIsFinished(state: FableCompilerState) = + match state.HasFSharpCompilationFinished, Set.isEmpty state.FilesCheckedButNotCompiled, state.ReplyChannel with + | true, true, Some channel -> + // Fable results are not guaranteed to be in order but revert them to make them closer to the original order + let fableResults = state.FableResults |> List.rev + Ok(state.FSharpLogs, fableResults) |> channel.Reply + | _ -> () + + static member Init(projCracked: ProjectCracked) = + async { + let checker = InteractiveChecker.Create(projCracked.ProjectOptions) + let! assemblies = checker.GetImportedAssemblies() + + let fableProj = + Project.From( + projCracked.ProjectFile, + projCracked.ProjectOptions, + [], + assemblies, + Log.log, + ?precompiledInfo = (projCracked.PrecompiledInfo |> Option.map (fun i -> i :> _)), + getPlugin = Reflection.loadType projCracked.CliArgs + ) + + return FableCompiler(checker, projCracked, fableProj) + } + + member _.CompileToFile(outFile: string) = + let filePaths, sourceReader = + Fable.Compiler.File.MakeSourceReader projCracked.SourceFiles + + checker.Compile(filePaths, sourceReader, outFile) + +type Watcher = + { + Watcher: FsWatcher + Subscription: IDisposable + StartedAt: DateTime + OnChange: ISet -> unit + } + + static member Create(watchDelay) = + { + Watcher = FsWatcher(watchDelay) + Subscription = + { new IDisposable with + member _.Dispose() = () + } + StartedAt = DateTime.MinValue + OnChange = ignore + } + + member this.Watch(projCracked: ProjectCracked) = + if this.StartedAt > projCracked.ProjectOptions.LoadTime then + this + else + Log.verbose (lazy "Watcher started!") + this.Subscription.Dispose() + + let subs = + this.Watcher.Observe + [ + projCracked.ProjectFile + yield! projCracked.References + yield! + projCracked.SourceFiles + |> Array.choose (fun f -> + let path = f.NormalizedFullPath + + if Naming.isInFableModules (path) then + None + else + Some path + ) + ] + |> Observable.subscribe this.OnChange + + { this with + Subscription = subs + StartedAt = DateTime.UtcNow + } + +type State = + { + CliArgs: CliArgs + ProjectCrackedAndFableCompiler: (ProjectCracked * FableCompiler) option + WatchDependencies: Map + PendingFiles: string[] + DeduplicateDic: ConcurrentDictionary + Watcher: Watcher option + SilentCompilation: bool + RecompileAllFiles: bool + UseMSBuildForCracking: bool + } + + member this.TriggeredByDependency(path: string, changes: ISet) = + match Map.tryFind path this.WatchDependencies with + | None -> false + | Some watchDependencies -> watchDependencies |> Array.exists changes.Contains + + member this.GetPathResolver(?precompiledInfo: PrecompiledInfoImpl) = + { new PathResolver with + member _.TryPrecompiledOutPath(sourceDir, relativePath) = + match precompiledInfo with + | None -> None + | Some precompiledInfo -> + let fullPath = IO.Path.Combine(sourceDir, relativePath) |> Path.normalizeFullPath + + precompiledInfo.TryPrecompiledOutPath(fullPath) + + member _.GetOrAddDeduplicateTargetDir(importDir: string, addTargetDir) = + // importDir must be trimmed and normalized by now, but lower it just in case + // as some OS use case insensitive paths + let importDir = importDir.ToLower() + + this.DeduplicateDic.GetOrAdd(importDir, (fun _ -> set this.DeduplicateDic.Values |> addTargetDir)) + } + + static member Create(cliArgs, ?watchDelay, ?recompileAllFiles, ?useMSBuildForCracking) = + { + CliArgs = cliArgs + ProjectCrackedAndFableCompiler = None + WatchDependencies = Map.empty + Watcher = watchDelay |> Option.map Watcher.Create + DeduplicateDic = ConcurrentDictionary() + PendingFiles = [||] + SilentCompilation = false + RecompileAllFiles = defaultArg recompileAllFiles false + UseMSBuildForCracking = defaultArg useMSBuildForCracking false + } + +let private getFilesToCompile + (state: State) + (changes: ISet) + (oldFiles: IDictionary option) + (projCracked: ProjectCracked) + = + let pendingFiles = set state.PendingFiles + + // Clear the hash of files that have changed + let projCracked = + projCracked.MapSourceFiles(fun file -> + if changes.Contains(file.NormalizedFullPath) then + Fable.Compiler.File(file.NormalizedFullPath) + else + file + ) + + let pathResolver = state.GetPathResolver() + + let filesToCompile = + projCracked.SourceFilePaths + |> Array.filter (fun path -> + changes.Contains path + || pendingFiles.Contains path + || state.TriggeredByDependency(path, changes) + || ( + match oldFiles with + | Some oldFiles -> not (oldFiles.ContainsKey(path)) + | None -> false + ) + || ( + // If files have been deleted, we should likely recompile after first deletion + let outPath = getOutPath state.CliArgs pathResolver path + + let wasDeleted = + (path.EndsWith(".fs") || path.EndsWith(".fsx")) && not (IO.File.Exists outPath) + + wasDeleted) + ) + + Log.verbose (lazy $"""Files to compile:{Log.newLine} {filesToCompile |> String.concat $"{Log.newLine} "}""") + + projCracked, filesToCompile + +let private areCompiledFilesUpToDate (state: State) (filesToCompile: string[]) = + try + let mutable foundCompiledFile = false + let pathResolver = state.GetPathResolver() + + let upToDate = + filesToCompile + |> Array.filter (fun file -> + file.EndsWith(".fs", StringComparison.Ordinal) + || file.EndsWith(".fsx", StringComparison.Ordinal) + ) + |> Array.forall (fun source -> + let outPath = getOutPath state.CliArgs pathResolver source + // Empty files are not written to disk so we only check date for existing files + if IO.File.Exists(outPath) then + foundCompiledFile <- true + + let upToDate = IO.File.GetLastWriteTime(source) < IO.File.GetLastWriteTime(outPath) + + if not upToDate then + Log.verbose ( + lazy + $"Output file {File.relPathToCurDir outPath} is older than {File.relPathToCurDir source}" + ) + + upToDate + else + true + ) + // If we don't find compiled files, assume we need recompilation + upToDate && foundCompiledFile + with er -> + Log.warning ("Cannot check timestamp of compiled files: " + er.Message) + false + +let private runProcessAndForget (cliArgs: CliArgs) (runProc: RunProcess) = + let workingDir = cliArgs.RootDir + + let exeFile = + File.tryNodeModulesBin workingDir runProc.ExeFile + |> Option.defaultValue runProc.ExeFile + + Process.startWithEnv cliArgs.RunProcessEnv workingDir exeFile runProc.Args + { cliArgs with RunProcess = None } + +let private checkRunProcess (state: State) (projCracked: ProjectCracked) (compilationExitCode: int) = + let cliArgs = state.CliArgs + + match cliArgs.RunProcess with + // Only run process if there are no errors + | _ when compilationExitCode <> 0 -> compilationExitCode, state + | None -> 0, state + | Some runProc -> + let workingDir = cliArgs.RootDir + + let findLastFileFullPath () = + let pathResolver = state.GetPathResolver() + let lastFile = Array.last projCracked.SourceFiles + getOutPath cliArgs pathResolver lastFile.NormalizedFullPath + + // Fable's getRelativePath version ensures there's always a period in front of the path: ./ + let findLastFileRelativePath () = + findLastFileFullPath () |> Path.getRelativeFileOrDirPath true workingDir false + + let exeFile, args = + match cliArgs.CompilerOptions.Language, runProc.ExeFile with + | Python, Naming.placeholder -> + let lastFilePath = findLastFileRelativePath () + "python", lastFilePath :: runProc.Args + | Rust, Naming.placeholder -> + let lastFileDir = IO.Path.GetDirectoryName(findLastFileFullPath ()) + + let args = + match File.tryFindUpwards "Cargo.toml" lastFileDir with + | Some path -> "--manifest-path" :: path :: runProc.Args + | None -> runProc.Args + + "cargo", "run" :: args + | Dart, Naming.placeholder -> + let lastFilePath = findLastFileRelativePath () + "dart", "run" :: lastFilePath :: runProc.Args + | JavaScript, Naming.placeholder -> + let lastFilePath = findLastFileRelativePath () + "node", lastFilePath :: runProc.Args + | (JavaScript | TypeScript), exeFile -> + File.tryNodeModulesBin workingDir exeFile |> Option.defaultValue exeFile, runProc.Args + | _, exeFile -> exeFile, runProc.Args + + if Option.isSome state.Watcher then + Process.startWithEnv cliArgs.RunProcessEnv workingDir exeFile args + + let runProc = + if runProc.IsWatch then + Some runProc + else + None + + 0, { state with CliArgs = { cliArgs with RunProcess = runProc } } + else + // TODO: When not in watch mode, run process out of this scope to free memory used by Fable/F# compiler + let exitCode = Process.runSyncWithEnv cliArgs.RunProcessEnv workingDir exeFile args + + exitCode, state + +let private compilationCycle (state: State) (changes: ISet) = + async { + let cliArgs = state.CliArgs + + let projCracked, fableCompiler, filesToCompile = + match state.ProjectCrackedAndFableCompiler with + | None -> + // #if DEBUG + // let evaluateOnly = true + // #else + let evaluateOnly = false + // #endif + let projCracked = + ProjectCracked.Init(cliArgs, state.UseMSBuildForCracking, evaluateOnly) + + projCracked, None, projCracked.SourceFilePaths + + | Some(projCracked, fableCompiler) -> + // For performance reasons, don't crack .fsx scripts for every change + let fsprojChanged = + changes |> Seq.exists (fun c -> c.EndsWith(".fsproj", StringComparison.Ordinal)) + + if fsprojChanged then + let oldProjCracked = projCracked + + let newProjCracked = + ProjectCracked.Init( + { cliArgs with NoCache = true }, + state.UseMSBuildForCracking, + evaluateOnly = true + ) + + // If only source files have changed, keep the project checker to speed up recompilation + let fableCompiler = + if oldProjCracked.ProjectOptions.OtherOptions = newProjCracked.ProjectOptions.OtherOptions then + Some fableCompiler + else + None + + let oldFiles = + oldProjCracked.SourceFiles + |> Array.map (fun f -> f.NormalizedFullPath, f) + |> dict + + let newProjCracked = + newProjCracked.MapSourceFiles(fun f -> + match oldFiles.TryGetValue(f.NormalizedFullPath) with + | true, f -> f + | false, _ -> f + ) + + let newProjCracked, filesToCompile = + getFilesToCompile state changes (Some oldFiles) newProjCracked + + newProjCracked, fableCompiler, filesToCompile + else + let changes = + if state.RecompileAllFiles then + HashSet projCracked.SourceFilePaths :> ISet<_> + else + changes + + let projCracked, filesToCompile = getFilesToCompile state changes None projCracked + + projCracked, Some fableCompiler, filesToCompile + + // Update the watcher (it will restart if the fsproj has changed) + // so changes while compiling get enqueued + let state = + { state with Watcher = state.Watcher |> Option.map (fun w -> w.Watch(projCracked)) } + + // If not in watch mode and projCracked.CanReuseCompiledFiles, skip compilation if compiled files are up-to-date + // NOTE: Don't skip Fable compilation in watch mode because we need to calculate watch dependencies + if + Option.isNone state.Watcher + && projCracked.CanReuseCompiledFiles + && areCompiledFilesUpToDate state filesToCompile + then + Log.always "Skipped compilation because all generated files are up-to-date!" + + let exitCode, state = checkRunProcess state projCracked 0 + + return + {| + State = state + ErrorLogs = [||] + OtherLogs = [||] + ExitCode = exitCode + |} + else + // Optimization for watch mode, if files haven't changed run the process as with --runFast + let state, cliArgs = + match cliArgs.RunProcess with + | Some runProc when + Option.isSome state.Watcher + && projCracked.CanReuseCompiledFiles + && not runProc.IsWatch + && runProc.ExeFile <> Naming.placeholder + && areCompiledFilesUpToDate state filesToCompile + -> + let cliArgs = runProcessAndForget cliArgs runProc + + { state with + CliArgs = cliArgs + SilentCompilation = true + }, + cliArgs + | _ -> state, cliArgs + + let! fableCompiler = + match fableCompiler with + | None -> FableCompiler.Init(projCracked) + | Some fableCompiler -> async.Return fableCompiler + + let! fsharpLogs, fableResults = + fableCompiler.StartCompilation( + projCracked.SourceFiles, // Make sure to pass the up-to-date source files (with cleared hashes for changed files) + filesToCompile, + state.GetPathResolver(?precompiledInfo = projCracked.PrecompiledInfo), + state.SilentCompilation, + fun f -> state.TriggeredByDependency(f, changes) + ) + + let logs, watchDependencies = + ((fsharpLogs, state.WatchDependencies), fableResults) + ||> List.fold (fun (logs, deps) -> + function + | Ok res -> + let logs = Array.append logs res.Logs + let deps = Map.add res.File res.WatchDependencies deps + logs, deps + | Error e -> + let log = + match e.Exception with + | Fable.FableError msg -> LogEntry.MakeError(msg, fileName = e.File) + | ex -> + let msg = ex.Message + Log.newLine + ex.StackTrace + + LogEntry.MakeError(msg, fileName = e.File, tag = "EXCEPTION") + + Array.append logs [| log |], deps + ) + + let state = + { state with + PendingFiles = [||] + WatchDependencies = watchDependencies + SilentCompilation = false + } + + let filesToCompile = set filesToCompile + + let errorLogs, otherLogs = + // Sometimes errors are duplicated + logs + |> Array.distinct + // Ignore warnings from packages in `fable_modules` folder + |> Array.filter (fun log -> + match log.Severity with + // We deal with errors later + | Severity.Error -> true + | Severity.Info + | Severity.Warning -> + match log.FileName with + | Some filename when + Naming.isInFableModules (filename) || not (filesToCompile.Contains(filename)) + -> + false + | _ -> true + ) + // We can't forward TreatWarningsAsErrors to FCS because it would generate errors + // in packages code (Fable recreate a single project with all packages source files) + // For this reason, we need to handle the conversion ourselves here + // It applies to all logs (Fable, F#, etc.) + |> fun logs -> + if projCracked.TreatmentWarningsAsErrors then + logs + |> Array.map (fun log -> + match log.Severity with + | Severity.Warning -> { log with Severity = Severity.Error } + | _ -> log + ) + else + logs + |> Array.partition (fun log -> log.Severity = Severity.Error) + + otherLogs + |> Array.iter (fun log -> + match log.Severity with + | Severity.Error -> () // In theory, we shouldn't have errors here + | Severity.Info -> formatLog cliArgs.RootDir log |> Log.always + | Severity.Warning -> formatLog cliArgs.RootDir log |> Log.warning + ) + + errorLogs |> Array.iter (formatLog cliArgs.RootDir >> Log.error) + let hasError = Array.isEmpty errorLogs |> not + + // Generate assembly and serialize info if precompile is selected + let! exitCode = + async { + match hasError, cliArgs.Precompile with + | false, true -> + let outPathsAndInlineExprs = + (Some(Map.empty, []), fableResults) + ||> List.fold (fun acc res -> + match acc, res with + | Some(outPaths, inlineExprs), Ok res -> + Some(Map.add res.File res.OutPath outPaths, res.InlineExprs :: inlineExprs) + | _ -> None + ) + + match outPathsAndInlineExprs with + | None -> return 1 + | Some(outPaths, inlineExprs) -> + // Assembly generation is single threaded but I couldn't make it work in parallel with serialization + // (if I use Async.StartChild, assembly generation doesn't seem to start until serialization is finished) + let dllPath = PrecompiledInfoImpl.GetDllPath(projCracked.FableModulesDir) + + Log.always ("Generating assembly...") + + let! (diagnostics, result), ms = + Performance.measureAsync <| fun _ -> fableCompiler.CompileToFile(dllPath) + + Log.always ($"Assembly generated in {ms}ms") + + match result with + | Some ex -> + getFSharpDiagnostics diagnostics |> logErrors cliArgs.RootDir + Log.error (ex.Message) + return 1 + | None -> + Log.always ($"Saving precompiled info...") + let! fableProj = fableCompiler.GetFableProject() + + let _, ms = + Performance.measure + <| fun _ -> + let inlineExprs = inlineExprs |> List.rev |> Array.concat + + let files = + fableProj.ImplementationFiles + |> Map.map (fun k v -> + match Map.tryFind k outPaths with + | Some outPath -> + { + RootModule = v.RootModule + OutPath = outPath + } + | None -> + Fable.FableError($"Cannot find out path for precompiled file {k}") + |> raise + ) + + PrecompiledInfoImpl.Save( + files = files, + inlineExprs = inlineExprs, + compilerOptions = cliArgs.CompilerOptions, + fableModulesDir = projCracked.FableModulesDir, + fableLibDir = projCracked.FableLibDir + ) + + Log.always ($"Precompiled info saved in {ms}ms") + return 0 + | _ -> return 0 + } + + // Run process + let exitCode, state = + if hasError then + 1 + else + exitCode + |> checkRunProcess state projCracked + + let state = + { state with + ProjectCrackedAndFableCompiler = Some(projCracked, fableCompiler) + PendingFiles = + if state.PendingFiles.Length = 0 then + errorLogs |> Array.choose (fun l -> l.FileName) |> Array.distinct + else + state.PendingFiles + } + + return + {| + State = state + ErrorLogs = errorLogs + OtherLogs = otherLogs + ExitCode = exitCode + |} + } + +type FileWatcherMsg = | Changes of timeStamp: DateTime * changes: ISet + +let startCompilationAsync state = + async { + try + let state = + match state.CliArgs.RunProcess with + | Some runProc when runProc.IsFast -> { state with CliArgs = runProcessAndForget state.CliArgs runProc } + | _ -> state + + // Initialize changes with an empty set + let changes = HashSet() :> ISet<_> + + let! compilationResult = + match state.Watcher with + | None -> compilationCycle state changes + | Some watcher -> + let agent = + MailboxProcessor.Start(fun agent -> + let rec loop state = + async { + match! agent.Receive() with + | Changes(timestamp, changes) -> + match state.Watcher with + // Discard changes that may have happened before we restarted the watcher + | Some w when w.StartedAt < timestamp -> + // TODO: Get all messages until QueueLength is 0 before starting the compilation cycle? + if changes.Count > 0 then + Log.verbose ( + lazy + $"""Changes:{Log.newLine} {changes |> String.concat $"{Log.newLine} "}""" + ) + + let! compilationResult = compilationCycle state changes + + Log.always $"Watching {File.relPathToCurDir w.Watcher.BasePath}" + + return! loop compilationResult.State + | _ -> return! loop state + } + + let onChange changes = + Changes(DateTime.UtcNow, changes) |> agent.Post + + loop { state with Watcher = Some { watcher with OnChange = onChange } } + ) + + // The watcher will remain active so we don't really need the reply channel, but leave loop on fatal errors + agent.PostAndAsyncReply(fun _ -> Changes(DateTime.UtcNow, changes)) |> ignore + + Async.FromContinuations(fun (_onSuccess, onError, _onCancel) -> agent.Error.Add(onError)) + + // TODO: We should propably keep them separated and even use a DUs to represents + // logs using one case per level + // type LogEntry = + // | Error of LogData + // | Warning of LogData + // but for now we are just rebuilding a single array because I don't know how to + // adapt the integrations tests suits + // Perhaps, we should make the integration tests code more simple + let logs = Array.append compilationResult.ErrorLogs compilationResult.OtherLogs + + match compilationResult.ExitCode with + | 0 -> return Ok(state, logs) + | _ -> return Error("Compilation failed", logs) + + with + | Fable.FableError e -> return Error(e, [||]) + | exn -> return raise exn + } diff --git a/src/Fable.Spectre.Cli/Pipeline.fs b/src/Fable.Spectre.Cli/Pipeline.fs new file mode 100644 index 000000000..dcd1116bb --- /dev/null +++ b/src/Fable.Spectre.Cli/Pipeline.fs @@ -0,0 +1,491 @@ +module Fable.Cli.Pipeline + +open System +open Fable +open Fable.AST +open Fable.Transforms +open Fable.Compiler.Util + +type Stream = + static member WriteToFile(memoryStream: IO.Stream, filePath: string) = + async { + memoryStream.Seek(0, IO.SeekOrigin.Begin) |> ignore + use fileStream = new IO.StreamWriter(filePath) + + do! memoryStream.CopyToAsync(fileStream.BaseStream) |> Async.AwaitTask + + do! fileStream.FlushAsync() |> Async.AwaitTask + return true + } + + static member IsEqualToFile(memoryStream: IO.Stream, targetPath: string) = + async { + let areStreamsEqual (stream1: IO.Stream) (stream2: IO.Stream) = + let buffer1 = Array.zeroCreate 1024 + let buffer2 = Array.zeroCreate 1024 + + let areBuffersEqual count1 count2 = + if count1 <> count2 then + false + else + let mutable i = 0 + let mutable equal = true + + while equal && i < count1 do + equal <- buffer1[i] = buffer2[i] + i <- i + 1 + + equal + + let rec areStreamsEqual () = + async { + let! count1 = stream1.AsyncRead(buffer1, 0, buffer1.Length) + + let! count2 = stream2.AsyncRead(buffer2, 0, buffer2.Length) + + match count1, count2 with + | 0, 0 -> return true + | count1, count2 when areBuffersEqual count1 count2 -> + if count1 < buffer1.Length then + return true + else + return! areStreamsEqual () + | _ -> return false + } + + areStreamsEqual () + + memoryStream.Seek(0, IO.SeekOrigin.Begin) |> ignore + use fileStream = IO.File.OpenRead(targetPath) + + return! areStreamsEqual memoryStream fileStream + } + + static member WriteToFileIfChanged(memoryStream: IO.Stream, targetPath: string) : Async = + async { + if memoryStream.Length = 0 then + return false + elif not (IO.File.Exists(targetPath)) then + return! Stream.WriteToFile(memoryStream, targetPath) + else + let fileInfo = new IO.FileInfo(targetPath) + + if fileInfo.Length <> memoryStream.Length then + return! Stream.WriteToFile(memoryStream, targetPath) + else + match! Stream.IsEqualToFile(memoryStream, targetPath) with + | false -> return! Stream.WriteToFile(memoryStream, targetPath) + | true -> return false + } + +module Js = + type BabelWriter + (com: Compiler, cliArgs: CliArgs, pathResolver: PathResolver, sourcePath: string, targetPath: string) + = + // In imports *.ts extensions have to be converted to *.js extensions instead + let fileExt = + let fileExt = cliArgs.CompilerOptions.FileExtension + + if fileExt.EndsWith(".ts", StringComparison.Ordinal) then + Path.ChangeExtension(fileExt, ".js") + else + fileExt + + let sourceDir = Path.GetDirectoryName(sourcePath) + let targetDir = Path.GetDirectoryName(targetPath) + let memoryStream = new IO.MemoryStream() + let stream = new IO.StreamWriter(memoryStream) + + let mapGenerator = + lazy (SourceMapSharp.SourceMapGenerator(?sourceRoot = cliArgs.SourceMapsRoot)) + + member _.WriteToFileIfChanged() = + async { + if cliArgs.SourceMaps then + let mapPath = targetPath + ".map" + + do! + stream.WriteLineAsync($"//# sourceMappingURL={IO.Path.GetFileName(mapPath)}") + |> Async.AwaitTask + + do! stream.FlushAsync() |> Async.AwaitTask + + let! written = Stream.WriteToFileIfChanged(memoryStream, targetPath) + + if written && cliArgs.SourceMaps then + use fs = IO.File.Open(targetPath + ".map", IO.FileMode.Create) + + do! mapGenerator.Force().toJSON().SerializeAsync(fs) |> Async.AwaitTask + + stream.Dispose() + } + + interface Printer.Writer with + // Don't dispose the stream here because we need to access the memory stream to check if file has changed + member _.Dispose() = () + + member _.Write(str) = + stream.WriteAsync(str) |> Async.AwaitTask + + member _.MakeImportPath(path) = + let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile) + + let path = + // TODO: Check precompiled out path for other languages too + match pathResolver.TryPrecompiledOutPath(sourceDir, path) with + | Some path -> Imports.getRelativePath sourceDir path + | None -> path + + let path = + Imports.getImportPath pathResolver sourcePath targetPath projDir cliArgs.OutDir path + + if path.EndsWith(".fs", StringComparison.Ordinal) then + let isInFableModules = Path.Combine(targetDir, path) |> Naming.isInFableModules + + File.changeExtensionButUseDefaultExtensionInFableModules JavaScript isInFableModules path fileExt + else + path + + member _.AddLog(msg, severity, ?range) = + com.AddLog(msg, severity, ?range = range, fileName = com.CurrentFile) + + member _.AddSourceMapping(srcLine, srcCol, genLine, genCol, file, displayName) = + if cliArgs.SourceMaps then + let generated: SourceMapSharp.Util.MappingIndex = + { + line = genLine + column = genCol + } + + let original: SourceMapSharp.Util.MappingIndex = + { + line = srcLine + column = srcCol + } + + let targetPath = Path.normalizeFullPath targetPath + + let sourcePath = + defaultArg file sourcePath + |> Path.getRelativeFileOrDirPath false targetPath false + + // This is a workaround for: + // https://github.com/fable-compiler/Fable/issues/3980 + // We are still investigating why some of the F# code don't have source information + // I believe for now we can ship it like that because it only deteriorate the source map + // it should not break them completely. + if srcLine <> 0 && srcCol <> 0 && file <> Some "unknown" then + mapGenerator.Force().AddMapping(generated, original, source = sourcePath, ?name = displayName) + + let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + async { + let babel = + FSharp2Fable.Compiler.transformFile com + |> FableTransforms.transformFile com + |> Fable2Babel.Compiler.transformFile com + + if not (isSilent || babel.IsEmpty) then + use writer = new BabelWriter(com, cliArgs, pathResolver, com.CurrentFile, outPath) + + do! BabelPrinter.run writer babel + // TODO: Check also if file has actually changed with other printers + do! writer.WriteToFileIfChanged() + } + +module Python = + // PEP8: Modules should have short, all-lowercase names Note that Python modules + // cannot contain dots or it will be impossible to import them + let normalizeFileName path = + Path.GetFileNameWithoutExtension(path).Replace(".", "_").Replace("-", "_") + |> Naming.applyCaseRule Core.CaseRules.SnakeCase + |> Py.Naming.checkPyKeywords + |> Py.Naming.checkPyStdlib + + let getTargetPath (cliArgs: CliArgs) (targetPath: string) = + let fileExt = cliArgs.CompilerOptions.FileExtension + let targetDir = Path.GetDirectoryName(targetPath) + let fileName = normalizeFileName targetPath + Path.Combine(targetDir, fileName + fileExt) + + type PythonFileWriter(com: Compiler, cliArgs: CliArgs, pathResolver, targetPath: string) = + let stream = new IO.StreamWriter(targetPath) + let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile) + let sourcePath = com.CurrentFile + + let bundleLibrary = + match cliArgs.FableLibraryPath with + | Some path when path.ToLowerInvariant() = Py.Naming.fableLibPyPI -> false + | _ -> true + + // Everything within the Fable hidden directory will be compiled as Library. We do this since the files there will be + // compiled as part of the main project which might be a program (Exe) or library (Library). + let isLibrary = + com.OutputType = OutputType.Library || Naming.isInFableModules com.CurrentFile + + let isFableLibrary = isLibrary && List.contains "FABLE_LIBRARY" com.Options.Define + + // For non-library files, import resolution must be done from the main directory + let targetPathForResolution = + if isLibrary then + targetPath + else + IO.Path.Join(defaultArg cliArgs.OutDir projDir, IO.Path.GetFileName(targetPath)) + |> Path.normalizeFullPath + + interface Printer.Writer with + member _.Write(str) = + stream.WriteAsync(str) |> Async.AwaitTask + + member _.Dispose() = stream.Dispose() + + member _.AddSourceMapping(_, _, _, _, _, _) = () + + member _.AddLog(msg, severity, ?range) = + com.AddLog(msg, severity, ?range = range, fileName = com.CurrentFile) + + member _.MakeImportPath(path) = + let relativePath parts = + parts + |> Array.mapi (fun i part -> + match part with + | "." when isLibrary -> Some "" + | ".." when isLibrary -> Some "." + | "." + | ".." -> None + | _ when i = parts.Length - 1 -> Some(normalizeFileName part) + | _ -> Some(part.Replace(".", "_")) // Do not lowercase dir names. See #3079 + ) + |> Array.choose id + |> String.concat "." + + let packagePath parts = + let mutable i = -1 + + parts + |> Array.choose (fun part -> + i <- i + 1 + + if part = "." then + if i = 0 && isLibrary then + Some("") + else + None + elif part = ".." then + None + elif part = Naming.fableModules && (not isLibrary) then + None + elif i = parts.Length - 1 then + Some(normalizeFileName part) + else + Some part // Do not normalize dir names. See #3079 + ) + |> String.concat "." + + if path.Contains('/') then + // If inside fable-library then use relative path + if isFableLibrary then + "." + normalizeFileName path + else + let outDir = + match cliArgs.OutDir with + | Some outDir -> Some outDir + // For files from the main program, always use an outDir to enforce resolution using targetPathForResolution + | None when not isLibrary -> Some projDir + | None -> None + + let resolvedPath = + Imports.getImportPath pathResolver sourcePath targetPathForResolution projDir outDir path + + let parts = resolvedPath.Split('/') + + match bundleLibrary with + | false -> packagePath parts + | _ -> relativePath parts + else + path + + // Writes __init__ files to all directories. This mailbox serializes and dedups. + let initFileWriter = + new MailboxProcessor(fun mb -> + async { + let rec loop (seen: Set) = + async { + let! outPath = mb.Receive() + + if (not (seen |> Set.contains outPath || (IO.File.Exists(outPath)))) then + do! IO.File.WriteAllTextAsync(outPath, "") |> Async.AwaitTask + + return! loop (seen.Add outPath) + } + + return! loop (set []) + } + ) + + initFileWriter.Start() + + let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + async { + let python = + FSharp2Fable.Compiler.transformFile com + |> FableTransforms.transformFile com + |> Python.Compiler.transformFile com + + if not (isSilent || PythonPrinter.isEmpty python) then + let writer = new PythonFileWriter(com, cliArgs, pathResolver, outPath) + + do! PythonPrinter.run writer python + + match com.OutputType with + | OutputType.Library -> + // Make sure we include an empty `__init__.py` in every directory of a library + let outPath = Path.Combine((Path.GetDirectoryName(outPath), "__init__.py")) + + initFileWriter.Post(outPath) + + | _ -> () + } + +module Php = + type PhpWriter(com: Compiler, cliArgs: CliArgs, pathResolver, targetPath: string) = + let sourcePath = com.CurrentFile + let fileExt = cliArgs.CompilerOptions.FileExtension + let stream = new IO.StreamWriter(targetPath) + + interface Printer.Writer with + member _.Write(str) = + stream.WriteAsync(str) |> Async.AwaitTask + + member _.MakeImportPath(path) = + let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile) + + let path = + Imports.getImportPath pathResolver sourcePath targetPath projDir cliArgs.OutDir path + + if path.EndsWith(".fs", StringComparison.Ordinal) then + Path.ChangeExtension(path, fileExt) + else + path + + member _.AddSourceMapping(_, _, _, _, _, _) = () + + member _.AddLog(msg, severity, ?range) = + com.AddLog(msg, severity, ?range = range, fileName = com.CurrentFile) + + member _.Dispose() = stream.Dispose() + + let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + async { + let php = + FSharp2Fable.Compiler.transformFile com + |> FableTransforms.transformFile com + |> Fable2Php.Compiler.transformFile com + + if not (isSilent || PhpPrinter.isEmpty php) then + use writer = new PhpWriter(com, cliArgs, pathResolver, outPath) + do! PhpPrinter.run writer php + } + +module Dart = + type DartWriter(com: Compiler, cliArgs: CliArgs, pathResolver, targetPath: string) = + let sourcePath = com.CurrentFile + let fileExt = cliArgs.CompilerOptions.FileExtension + let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile) + let stream = new IO.StreamWriter(targetPath) + + interface Printer.Writer with + member _.Write(str) = + stream.WriteAsync(str) |> Async.AwaitTask + + member _.MakeImportPath(path) = + let path = + Imports.getImportPath pathResolver sourcePath targetPath projDir cliArgs.OutDir path + + if path.EndsWith(".fs", StringComparison.Ordinal) then + Path.ChangeExtension(path, fileExt) + else + path + + member _.AddSourceMapping(_, _, _, _, _, _) = () + + member _.AddLog(msg, severity, ?range) = + com.AddLog(msg, severity, ?range = range, fileName = com.CurrentFile) + + member _.Dispose() = stream.Dispose() + + let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + async { + let file = + FSharp2Fable.Compiler.transformFile com + |> FableTransforms.transformFile com + |> Fable2Dart.Compiler.transformFile com + + if not (isSilent || DartPrinter.isEmpty file) then + use writer = new DartWriter(com, cliArgs, pathResolver, outPath) + do! DartPrinter.run writer file + } + +module Rust = + open Fable.Transforms.Rust + + type RustWriter(com: Compiler, cliArgs: CliArgs, pathResolver, targetPath: string) = + let sourcePath = com.CurrentFile + let fileExt = cliArgs.CompilerOptions.FileExtension + let stream = new IO.StreamWriter(targetPath) + + interface Printer.Writer with + member self.Write(str) = + + let str = + // rewrite import paths in last file + if com.CurrentFile = (Array.last com.SourceFiles) then + System.Text.RegularExpressions.Regex.Replace( + str, + @"(#\[path\s*=\s*\"")([^""]*)(\""])", + fun m -> + let path = (self :> Printer.Writer).MakeImportPath(m.Groups[2].Value) + m.Groups[1].Value + path + m.Groups[3].Value + ) + else + str + + stream.WriteAsync(str) |> Async.AwaitTask + + member _.MakeImportPath(path) = + let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile) + + let path = + Imports.getImportPath pathResolver sourcePath targetPath projDir cliArgs.OutDir path + + if path.EndsWith(".fs", StringComparison.Ordinal) then + Path.ChangeExtension(path, fileExt) + else + path + + member _.AddSourceMapping(_, _, _, _, _, _) = () + + member _.AddLog(msg, severity, ?range) = + com.AddLog(msg, severity, ?range = range, fileName = com.CurrentFile) + + member _.Dispose() = stream.Dispose() + + let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + async { + let crate = + FSharp2Fable.Compiler.transformFile com + |> FableTransforms.transformFile com + |> Fable2Rust.Compiler.transformFile com + + if not (isSilent || RustPrinter.isEmpty crate) then + use writer = new RustWriter(com, cliArgs, pathResolver, outPath) + do! RustPrinter.run writer crate + } + +let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) = + match com.Options.Language with + | JavaScript + | TypeScript -> Js.compileFile com cliArgs pathResolver isSilent outPath + | Python -> Python.compileFile com cliArgs pathResolver isSilent outPath + | Php -> Php.compileFile com cliArgs pathResolver isSilent outPath + | Dart -> Dart.compileFile com cliArgs pathResolver isSilent outPath + | Rust -> Rust.compileFile com cliArgs pathResolver isSilent outPath diff --git a/src/Fable.Spectre.Cli/Printers.fs b/src/Fable.Spectre.Cli/Printers.fs new file mode 100644 index 000000000..5697f59ec --- /dev/null +++ b/src/Fable.Spectre.Cli/Printers.fs @@ -0,0 +1,216 @@ +module Fable.Cli.Printers + +open System.IO +open FSharp.Compiler.Symbols +open Fable +open Fable.Compiler.Util + +let attribsOfSymbol (s: FSharpSymbol) = + [ + match s with + | :? FSharpField as v -> + yield "field" + + if v.IsCompilerGenerated then + yield "compgen" + + if v.IsDefaultValue then + yield "default" + + if v.IsMutable then + yield "mutable" + + if v.IsVolatile then + yield "volatile" + + if v.IsStatic then + yield "static" + + if v.IsLiteral then + yield sprintf "%A" v.LiteralValue.Value + + | :? FSharpEntity as v -> + v.TryFullName |> ignore // check there is no failure here + + match v.BaseType with + | Some t when t.HasTypeDefinition && t.TypeDefinition.TryFullName.IsSome -> + yield sprintf "inherits %s" t.TypeDefinition.FullName + | _ -> () + + if v.IsNamespace then + yield "namespace" + + if v.IsFSharpModule then + yield "module" + + if v.IsByRef then + yield "byref" + + if v.IsClass then + yield "class" + + if v.IsDelegate then + yield "delegate" + + if v.IsEnum then + yield "enum" + + if v.IsFSharpAbbreviation then + yield "abbrev" + + if v.IsFSharpExceptionDeclaration then + yield "exception" + + if v.IsFSharpRecord then + yield "record" + + if v.IsFSharpUnion then + yield "union" + + if v.IsInterface then + yield "interface" + + if v.IsMeasure then + yield "measure" + // if v.IsProvided then yield "provided" + // if v.IsStaticInstantiation then yield "static_inst" + // if v.IsProvidedAndErased then yield "erased" + // if v.IsProvidedAndGenerated then yield "generated" + if v.IsUnresolved then + yield "unresolved" + + if v.IsValueType then + yield "valuetype" + + | :? FSharpMemberOrFunctionOrValue as v -> + yield + "owner: " + + match v.DeclaringEntity with + | Some e -> e.CompiledName + | _ -> "" + + if v.IsActivePattern then + yield "active_pattern" + + if v.IsDispatchSlot then + yield "dispatch_slot" + + if v.IsModuleValueOrMember && not v.IsMember then + yield "val" + + if v.IsMember then + yield "member" + + if v.IsProperty then + yield "property" + + if v.IsExtensionMember then + yield "extension_member" + + if v.IsPropertyGetterMethod then + yield "property_getter" + + if v.IsPropertySetterMethod then + yield "property_setter" + + if v.IsEvent then + yield "event" + + if v.EventForFSharpProperty.IsSome then + yield "property_event" + + if v.IsEventAddMethod then + yield "event_add" + + if v.IsEventRemoveMethod then + yield "event_remove" + + if v.IsTypeFunction then + yield "type_func" + + if v.IsCompilerGenerated then + yield "compiler_gen" + + if v.IsImplicitConstructor then + yield "implicit_ctor" + + if v.IsMutable then + yield "mutable" + + if v.IsOverrideOrExplicitInterfaceImplementation then + yield "override_impl" + + if not v.IsInstanceMember then + yield "static" + + if + v.IsInstanceMember + && not v.IsInstanceMemberInCompiledCode + && not v.IsExtensionMember + then + yield "funky" + + if v.IsExplicitInterfaceImplementation then + yield "interface_impl" + + yield sprintf "%A" v.InlineAnnotation + // if v.IsConstructorThisValue then yield "ctorthis" + // if v.IsMemberThisValue then yield "this" + // if v.LiteralValue.IsSome then yield "literal" + | _ -> () + ] + +let rec printFSharpDecls prefix decls = + seq { + let mutable i = 0 + + for decl in decls do + i <- i + 1 + + match decl with + | FSharpImplementationFileDeclaration.Entity(e, sub) -> + yield sprintf "%s%i) ENTITY: %s %A" prefix i e.CompiledName (attribsOfSymbol e) + + if not (Seq.isEmpty e.Attributes) then + yield sprintf "%sattributes: %A" prefix (Seq.toList e.Attributes) + + if not (Seq.isEmpty e.DeclaredInterfaces) then + yield sprintf "%sinterfaces: %A" prefix (Seq.toList e.DeclaredInterfaces) + + yield "" + yield! printFSharpDecls (prefix + "\t") sub + | FSharpImplementationFileDeclaration.MemberOrFunctionOrValue(meth, args, body) -> + yield sprintf "%s%i) METHOD: %s %A" prefix i meth.CompiledName (attribsOfSymbol meth) + + yield sprintf "%stype: %A" prefix meth.FullType + yield sprintf "%sargs: %A" prefix args + // if not meth.IsCompilerGenerated then + yield sprintf "%sbody: %A" prefix body + yield "" + | FSharpImplementationFileDeclaration.InitAction(expr) -> + yield sprintf "%s%i) ACTION" prefix i + yield sprintf "%s%A" prefix expr + yield "" + } + +let printFableDecls decls = + seq { + for decl in decls do + yield sprintf "%A" decl + } + +let printAst outDir (implFiles: FSharpImplementationFileContents list) = + if Directory.Exists(outDir) |> not then + Directory.CreateDirectory(outDir) |> ignore + + for implFile in implFiles do + let target = + let fileName = Path.GetFileNameWithoutExtension(implFile.FileName) + Path.Combine(outDir, fileName + ".fs.ast") + + Log.verbose (lazy sprintf "Print AST %s" target) + + printFSharpDecls "" implFile.Declarations + |> fun lines -> File.WriteAllLines(target, lines) +// printFableDecls fableFile.Declarations +// |> fun lines -> System.IO.File.WriteAllLines(Path.Combine(outDir, fileName + ".fable.ast"), lines) diff --git a/src/Fable.Spectre.Cli/README.md b/src/Fable.Spectre.Cli/README.md new file mode 100644 index 000000000..248706d9e --- /dev/null +++ b/src/Fable.Spectre.Cli/README.md @@ -0,0 +1,102 @@ +# CLI + +This reworked CLI uses Spectre.Console to manage the CLI interface. +Using the framework, we are able to reason about the settings more +intuitively, although it uses a C# like OOP approach. + +The value provided by offloading the CLI abstraction to a library for +the Fable CLI tool is worth this cost. + +## Backwards Compatibility + +This implementation permits all operations being handled from the default +command, except for `clean`. This maintains backwards compatibility. + +All previous switches and commands are supported the same. + +Deprecated options such as `--verbose` and `--silent` are still functional, +although now hidden. These switches are superseded by the `--verbosity` +option. `--verbosity` takes precedence over the deprecated switches. + +## Future Proofing + +As more targets mature in Fable, the need to separate CLI options and switches +for the different targets will grow. + +While the backing compilation system is not changed, the CLI interface is +designed for this eventuality. + +```mermaid +flowchart + subgraph Interfaces + subgraph targetAgnostic [Target Agnostic Args] + ICommonArgs + ICompilingArgs + end + subgraph targetSpecific [Target Specific Args] + IJavaScriptArgs + ITypeScriptArgs + IPythonArgs + IRustArgs + IPhpArgs + IDartArgs + end + ICommonArgs --> ICompilingArgs + ICompilingArgs --> IJavaScriptArgs + ICompilingArgs --> ITypeScriptArgs + ICompilingArgs --> IPythonArgs + ICompilingArgs --> IRustArgs + ICompilingArgs --> IPhpArgs + ICompilingArgs --> IDartArgs + IJavaScriptArgs --> ICliArgs + ITypeScriptArgs --> ICliArgs + IPythonArgs --> ICliArgs + IRustArgs --> ICliArgs + IPhpArgs --> ICliArgs + IDartArgs --> ICliArgs + end + subgraph Settings + ICliArgs --Superset of interfaces implemented + by base settings class without + exposing any options--> FableBase + FableBase --Exposes all + interface args + as options--> Fable + FableBase --Same as Fable + but has watch switch on--> Watch + FableBase --Only exposes + the ICommonArgs subset + of args as options--> Clean + FableBase --Exposes common + compiling args as + options, but excludes + options that are preset + by commands like language + and watch related options--> CompilingBase + CompilingBase --Exposes JavaScript + specific args as options--> JavaScript + CompilingBase --> TypeScript + JavaScript --Exposes watch related + options--> JavaScriptWatch + TypeScript --> TypeScriptWatch + ...Others --> ...OthersWatch + CompilingBase --> ...Others + end + Interfaces ---> Settings +``` + +This provides a structure that is compatible with the current +compiler backing, as all `Setting` types +are compatible with the superset of args. +This is a non issue for the CLI consumers, +as each `Setting` type only exposes their +specific options via attributes. + +The structure lends itself to iteration +towards allowing targets to safely add +their own switches as desired, and also +create consuming functions of the CLI interface +for different targets to handle their +subset of Cli args only, with intellisense +completions that are not polluted by other +target language options. diff --git a/src/Fable.Spectre.Cli/Settings/Clean.fs b/src/Fable.Spectre.Cli/Settings/Clean.fs new file mode 100644 index 000000000..218f0a1b5 --- /dev/null +++ b/src/Fable.Spectre.Cli/Settings/Clean.fs @@ -0,0 +1,61 @@ +module Fable.Spectre.Cli.Settings.Clean + +open System.ComponentModel +open Fable +open Spectre.Console.Cli + +type CleanSettings() = + inherit FableSettingsBase() + + [] + [] + member this.extensionArg + with get () = this.extension + and set value = this.extension <- value + + override val language = JavaScript with get, set + + [] + override val legacyCracker = false with get, set + + [] + override val version = false with get, set + + [] + override val langString = "javascript" with get, set + + [] + override val excludePatterns = [||] with get, set + + [] + override val yes = false with get, set + // [] + override val runCommandFast = "" with get, set + // [] + override val config = "Release" with get, set + // [] + override val definitions = [||] with get, set + // [] + override val sourceMapsRoot = "" with get, set + // [] + override val sourceMaps = false with get, set + // [] + override val watch = false with get, set + // [] + override val typedArrays = true with get, set + // [] + override val optimize = false with get, set + // [] + override val runCommandWatch = "" with get, set + // [] + override val runScript = "" with get, set + // [] + override val runCommand = "" with get, set + // [] + override val noRestore = false with get, set + // [] + override val watchDelay = 200 with get, set + // [] + override val outputString = "" with get, set + // [] + override val noCache = false with get, set diff --git a/src/Fable.Spectre.Cli/Settings/Compile.fs b/src/Fable.Spectre.Cli/Settings/Compile.fs new file mode 100644 index 000000000..b3f4ab5cd --- /dev/null +++ b/src/Fable.Spectre.Cli/Settings/Compile.fs @@ -0,0 +1,261 @@ +module Fable.Spectre.Cli.Settings.Compile + +open Fable +open Spectre.Console.Cli + +[] +type CompileSettingsBase(language: Language) = + inherit FableSettingsBase() + override val language = language with get, set + override val langString = language.ToString() with get, set + + [] + override val runCommandFast = "" with get, set + + [] + override val config = "Release" with get, set + + [] + override val definitions = [||] with get, set + + [] + override val legacyCracker = false with get, set + + [] + override val optimize = false with get, set + + [] + override val runCommand = "" with get, set + + [] + override val version = false with get, set + + [] + override val noRestore = false with get, set + + [] + override val outputString = "" with get, set + + [] + override val excludePatterns = [||] with get, set + + [] + override val noCache = false with get, set +// [] +// override val yes = false with get,set +// [] +// override val sourceMapsRoot = "" with get,set +// [] +// override val sourceMaps = false with get,set +// [] +// override val typedArrays = true with get,set +// [] +// override val runScript = "" with get,set + + +type WatchSettings() = + inherit FableSettingsBase() + override val language = JavaScript with get, set + override val watch = true with get, set + override val yes = true with get, set + + [] + override val langString = "javascript" with get, set + + [] + override val runCommandFast = "" with get, set + + [] + override val config = "Debug" with get, set + + [] + override val definitions = [||] with get, set + + [] + override val legacyCracker = false with get, set + + [] + override val optimize = false with get, set + + [] + override val runCommandWatch = "" with get, set + + [] + override val runCommand = "" with get, set + + [] + override val version = false with get, set + + [] + override val noRestore = false with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val outputString = "" with get, set + + [] + override val excludePatterns = [||] with get, set + + [] + override val noCache = false with get, set + + [] + override val sourceMapsRoot = "" with get, set + + [] + override val sourceMaps = false with get, set + + [] + override val typedArrays = true with get, set + + [] + override val runScript = "" with get, set + +type JavaScriptSettings() = + inherit CompileSettingsBase(JavaScript) + override val watch = false with get, set + override val watchDelay = 200 with get, set + override val runCommandWatch = "" with get, set + + [] + override val yes = false with get, set + + [] + override val sourceMapsRoot = "" with get, set + + [] + override val sourceMaps = false with get, set + + [] + override val typedArrays = true with get, set + + [] + override val runScript = "" with get, set + +type JavaScriptWatchSettings() = + inherit JavaScriptSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set + +type TypeScriptSettings() = + inherit CompileSettingsBase(TypeScript) + override val watch = false with get, set + override val watchDelay = 200 with get, set + + [] + override val yes = false with get, set + + [] + override val sourceMapsRoot = "" with get, set + + [] + override val sourceMaps = false with get, set + // false in typescript + [] + override val typedArrays = false with get, set + + [] + override val runScript = "" with get, set + + override val runCommandWatch = "" with get, set + +type TypeScriptWatchSettings() = + inherit TypeScriptSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set + +type PythonSettings() = + inherit CompileSettingsBase(Python) + override val watch = false with get, set + override val watchDelay = 200 with get, set + override val yes = false with get, set + override val sourceMapsRoot = "" with get, set + override val sourceMaps = false with get, set + override val typedArrays = false with get, set + override val runScript = "" with get, set + override val runCommandWatch = "" with get, set + +type PythonWatchSettings() = + inherit PythonSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set + +type DartSettings() = + inherit CompileSettingsBase(Dart) + override val watch = false with get, set + override val watchDelay = 200 with get, set + override val yes = false with get, set + override val sourceMapsRoot = "" with get, set + override val sourceMaps = false with get, set + override val typedArrays = false with get, set + override val runScript = "" with get, set + override val runCommandWatch = "" with get, set + +type DartWatchSettings() = + inherit DartSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set + +type RustSettings() = + inherit CompileSettingsBase(Rust) + override val watch = false with get, set + override val watchDelay = 200 with get, set + override val yes = false with get, set + override val sourceMapsRoot = "" with get, set + override val sourceMaps = false with get, set + override val typedArrays = false with get, set + override val runScript = "" with get, set + override val runCommandWatch = "" with get, set + +type RustWatchSettings() = + inherit RustSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set + +type PhpSettings() = + inherit CompileSettingsBase(Php) + override val watch = false with get, set + override val watchDelay = 200 with get, set + override val yes = false with get, set + override val sourceMapsRoot = "" with get, set + override val sourceMaps = false with get, set + override val typedArrays = false with get, set + override val runScript = "" with get, set + override val runCommandWatch = "" with get, set + +type PhpWatchSettings() = + inherit PhpSettings() + override val watch = true with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val runCommandWatch = "" with get, set diff --git a/src/Fable.Spectre.Cli/Settings/Root.fs b/src/Fable.Spectre.Cli/Settings/Root.fs new file mode 100644 index 000000000..3cc7508a3 --- /dev/null +++ b/src/Fable.Spectre.Cli/Settings/Root.fs @@ -0,0 +1,129 @@ +module Fable.Spectre.Cli.Settings.Root + +open Fable +open Spectre.Console.Cli + +type FableSettings() = + inherit FableSettingsBase() + override val language = JavaScript with get, set + + [] + override val runCommandFast = "" with get, set + + [] + override val config = "Release" with get, set + + [] + override val definitions = [||] with get, set + + [] + override val sourceMapsRoot = "" with get, set + + [] + override val sourceMaps = false with get, set + + [] + override val watch = false with get, set + + [] + override val legacyCracker = false with get, set + + [] + override val typedArrays = true with get, set + + [] + override val optimize = false with get, set + + [] + override val runCommandWatch = "" with get, set + + [] + override val runScript = "" with get, set + + [] + override val runCommand = "" with get, set + + [] + override val version = false with get, set + + [] + override val langString = "javascript" with get, set + + [] + override val noRestore = false with get, set + + [] + override val watchDelay = 200 with get, set + + [] + override val outputString = "" with get, set + + [] + override val excludePatterns = [||] with get, set + + [] + override val noCache = false with get, set + + [] + override val yes = false with get, set + +type FablePrecompileSettings() = + inherit FableSettingsBase() + override val language = JavaScript with get, set + + [] + override val runCommandFast = "" with get, set + + [] + override val config = "Release" with get, set + + [] + override val definitions = [||] with get, set + + [] + override val sourceMapsRoot = "" with get, set + + [] + override val sourceMaps = false with get, set + // [] + override val watch = false with get, set + + [] + override val legacyCracker = false with get, set + + [] + override val typedArrays = true with get, set + + [] + override val optimize = false with get, set + // [] + override val runCommandWatch = "" with get, set + + [] + override val runScript = "" with get, set + + [] + override val runCommand = "" with get, set + + [] + override val version = false with get, set + + [] + override val langString = "javascript" with get, set + + [] + override val noRestore = false with get, set + // [] + override val watchDelay = 200 with get, set + + [] + override val outputString = "" with get, set + + [] + override val excludePatterns = [||] with get, set + + [] + override val noCache = false with get, set + + [] + override val yes = false with get, set diff --git a/src/Fable.Spectre.Cli/Settings/Types.fs b/src/Fable.Spectre.Cli/Settings/Types.fs new file mode 100644 index 000000000..221b5d85e --- /dev/null +++ b/src/Fable.Spectre.Cli/Settings/Types.fs @@ -0,0 +1,488 @@ +namespace Fable.Spectre.Cli.Settings + +open System +open System.ComponentModel +open Fable +open Fable.Compiler.Util +open Fable.Spectre.Cli.Settings.Spec +open Spectre.Console +open Spectre.Console.Cli +open Spectre.Console.Cli.Help + +module internal Commands = + module Deprecated = + // Deprecated // TODO - remove next version + [] + let verbose = "--verbose" + + [] + let silent = "--silent" + + module Hidden = + // Hidden + [] + let precompiledLib = "--precompiledLib " + + [] + let replace = "--replace " + + [] + let printAst = "--printAst" + + [] + let noReflection = "--noReflection" + + [] + let noParallelTypeCheck = "--noParallelTypeCheck" + + [] + let trimRootModule = "--trimRootModule" + + [] + let fableLib = "--fableLib " + // Standard + [] + let language = "-l|--language " + + [] + let version = "--version" + + [] + let cwd = "--cwd " + + [] + let verbosity = "--verbosity " + + [] + let extension = "-e|--extension" + + [] + let yes = "--yes" + + [] + let output = "-o|--output " + + [] + let definition = "--define " + + [] + let config = "-c|--configuration " + + [] + let watch = "--watch" + + [] + let watchDelay = "--watchDelay " + + [] + let run = "--run " + + [] + let runFast = "--runFast " + + [] + let runWatch = "--runWatch " + + [] + let noRestore = "--noRestore" + + [] + let noCache = "--noCache" + + [] + let excludePatterns = "--exclude " + + [] + let optimize = "--optimize" + + [] + let legacyCracker = "--legacyCracker" + + [] + let runScript = "--runScript " + + [] + let typedArrays = "--typedArrays " + + [] + let sourceMaps = "-s|--sourceMaps" + + [] + let sourceMapsRoot = "--sourceMapsRoot " + +[] +type FableSettingsBase() = + inherit CommandSettings() + // Fields that we map to from 'input' properties + member val replace: Map = Map([]) with get, set + member val verbosity: Verbosity = Verbosity.Normal with get, set + abstract language: Language with get, set + + [] + abstract langString: string with get, set + + [] + abstract version: bool with get, set + + [] + abstract yes: bool with get, set + + [] + abstract outputString: string with get, set + + [] + abstract definitions: string[] with get, set + + [] + abstract config: string with get, set + + [] + abstract watch: bool with get, set + + [] + abstract watchDelay: int with get, set + + [] + abstract runCommand: string with get, set + + [] + abstract runCommandFast: string with get, set + + [] + abstract runCommandWatch: string with get, set + + [] + abstract noRestore: bool with get, set + + [] + abstract noCache: bool with get, set + + [] + abstract excludePatterns: string[] with get, set + + [] + abstract optimize: bool with get, set + + [] + abstract legacyCracker: bool with get, set + + [] + abstract runScript: string with get, set + + [] + abstract typedArrays: bool with get, set + + [] + abstract sourceMaps: bool with get, set + + [] + abstract sourceMapsRoot: string with get, set + + // Default/common options + [] + member val verbose = false with get, set + + [] + member val silent = false with get, set + + [] + [] + member val extension = ".fs.js" with get, set + + [] + [] + member val cwd = Environment.CurrentDirectory with get, set + + [] + [] + member val verbosityString = "normal" with get, set + + [] + [] + member val projPath = Environment.CurrentDirectory with get, set + + // hidden options + [] + member val replaceStrings = [||] with get, set + + [] + member val noParallelTypeCheck = false with get, set + + [] + member val precompiledLib = "" with get, set + + [] + member val printAst = false with get, set + + [] + member val trimRootModule = false with get, set + + [] + member val fableLib = "" with get, set + + [] + member val noReflection = false with get, set + + interface ICommonArgs with + member this.extension = + let prependDotIfRequired: string -> string = + function + | input when input.StartsWith('.') -> input + | input -> "." + input + + match this.language with + | JavaScript -> this.extension |> prependDotIfRequired + | _ when this.extension <> ".fs.js" -> this.extension |> prependDotIfRequired + | TypeScript -> ".fs.ts" + | Python -> ".py" + | Php -> ".php" + | Dart -> ".dart" + | Rust -> ".rs" + + member this.projPath = this.projPath + member this.language = this.language + member this.silent = this.silent + member this.uncaughtArgs = [] + member this.verbosity = this.verbosity + member this.version = this.version + member this.workingDirectory = this.cwd + member this.yes = this.yes + + interface ICompilingArgs with + member this.configuration = this.config + member this.exclude = this.excludePatterns + + member this.fableLib = + if this.fableLib |> String.IsNullOrWhiteSpace then + None + else + Some this.fableLib + + member this.legacyCracker = this.legacyCracker + member this.noCache = this.noCache + member this.noParallelTypeCheck = this.noParallelTypeCheck + member this.noReflection = this.noReflection + member this.noRestore = this.noRestore + member this.optimize = this.optimize + + member this.precompiledLib = + if this.precompiledLib |> String.IsNullOrWhiteSpace then + None + else + Some this.precompiledLib + + member this.printAst = this.printAst + member this.replace = this.replace + + member this.run = + if this.runCommand |> String.IsNullOrWhiteSpace then + None + else + Some this.runCommand + + member this.runFast = + if this.runCommandFast |> String.IsNullOrWhiteSpace then + None + else + Some this.runCommandFast + + member this.runWatch = + if this.runCommandWatch |> String.IsNullOrWhiteSpace then + None + else + Some this.runCommandWatch + + member this.definitions = + [ + "FABLE_COMPILER" + "FABLE_COMPILER_5" + this.language.ToString().ToUpper() |> sprintf "FABLE_COMPILER_%s" + yield! this.definitions + ] + |> List.distinct + + member this.trimRootModule = this.trimRootModule + member this.watch = this.watch + member this.watchDelay = this.watchDelay + + member this.outputDirectory = + if this.outputString |> String.IsNullOrWhiteSpace then + None + else + Some this.outputString + + interface IJavaScriptArgs with + member this.runScript = + if this.runScript |> String.IsNullOrWhiteSpace then + None + else + Some this.runScript + + member this.sourceMap = this.sourceMaps + + member this.sourceMapRoot = + if this.sourceMapsRoot |> String.IsNullOrWhiteSpace then + None + else + Some this.sourceMapsRoot + + member this.typedArrays = this.typedArrays + + interface ITypeScriptArgs + interface IDartArgs + interface IRustArgs + interface IPhpArgs + interface IPythonArgs + interface ICliArgs + + member this.NormalizeAbsolutePath(path: string) = + (if IO.Path.IsPathRooted(path) then + path + else + IO.Path.Combine(this.cwd, path)) + // Use getExactFullPath to remove things like: myrepo/./build/ + // and get proper casing (see `getExactFullPath` comment) + |> File.getExactFullPath + |> Path.normalizePath + + // Prefix validation functions with 'validateAndSet' if they + // mutate a property on validation. + override this.Validate() = + let validatePrecompiledLibCompatibility acc = + match this.fableLib, this.precompiledLib with + | "", _ + | _, "" -> acc + | _, _ -> "[--precompiledLib] & [--fableLib] are incompatible." :: acc + + let validateAndSetCwd acc = // impure + if System.IO.Path.Exists(this.cwd) then + this.cwd <- this.NormalizeAbsolutePath this.cwd + acc + else + $"[--cwd] Working directory does not exist: {this.cwd}" :: acc + + let validateAndSetProjPath acc = // impure + if System.IO.Path.Exists(this.projPath) then + this.projPath <- this.NormalizeAbsolutePath this.projPath + acc + else + $"[PATH] Path does not exists: " + this.projPath :: acc + + let validateAndSetVerbosity acc = // impure + match this.verbosityString.ToLower() with + | "normal" when this.verbose -> + this.verbosity <- Verbosity.Verbose + acc + | "normal" when this.silent -> + this.verbosity <- Verbosity.Silent + acc + | "n" + | "normal" -> + this.verbosity <- Verbosity.Normal + acc + | "d" + | "debug" + | "v" + | "verbose" -> + this.verbosity <- Verbosity.Verbose + acc + | "s" + | "silent" -> + this.verbosity <- Verbosity.Silent + acc + | value -> + $"[--verbosity] Verbosity can be one of [(verbose|debug)|silent|normal], but got '{value}'" + :: acc + + let validateAndSetReplace acc = // impure + this.replaceStrings + |> Array.filter (String.exists ((=) ':') >> not) + |> function + | [||] -> + this.replace <- + this.replaceStrings + |> Array.map (_.Split(':') >> fun arr -> arr[0], arr[1]) + |> Map + + acc + | arr -> + arr + |> Array.map (sprintf "[--replace] Expected ':' pair but got: %s") + |> Array.toList + |> (@) acc + + let validateConfiguration acc = + match this.config with + | "Debug" // TODO should this ignore case? + | "Release" -> acc + | value -> $"[--configuration] Expected one of [ Debug | Release ] but got: {value}" :: acc + + let validateAndSetLanguage (input: string) acc = // impure + match input.ToLower() with + | "js" + | "javascript" -> + this.language <- JavaScript + acc + | "py" + | "python" -> + this.language <- Python + acc + | "rs" + | "rust" -> + this.language <- Rust + acc + | "ts" + | "typescript" -> + this.language <- TypeScript + acc + | "php" -> + this.language <- Php + acc + | "dart" -> + this.language <- Dart + acc + | value -> + $"[-lang|--language] Unknown language target: '{value}' + + Available Options: + - js | javascript + - ts | typescript + - py | python + - rs | rust + - php + - dart" + :: acc + + let validateOutputDirectoryCompatibility acc = + Path.GetDirectoryName this.outputString + |> function + | "obj" + | Naming.fableModules as dir -> + $"[-o|--output] {dir} is a reserved directory, please use another directory." + :: acc + | _ -> acc + + let validation = // impure + [] + |> validatePrecompiledLibCompatibility + |> validateOutputDirectoryCompatibility + |> validateConfiguration + |> validateAndSetCwd + |> validateAndSetVerbosity + |> validateAndSetReplace + |> validateAndSetProjPath + |> validateAndSetLanguage this.langString + + if validation.IsEmpty then + ValidationResult.Success() + else + ValidationResult.Error(validation |> String.concat "\n") diff --git a/src/Fable.Spectre.Cli/Spec.fs b/src/Fable.Spectre.Cli/Spec.fs new file mode 100644 index 000000000..4242921d7 --- /dev/null +++ b/src/Fable.Spectre.Cli/Spec.fs @@ -0,0 +1,116 @@ +module Fable.Spectre.Cli.Settings.Spec + +open Fable +open Spectre.Console.Cli + +// All arguments and flags should be available to the root executable. +// The use of commands such as 'clean' with --help will show the sublist +// of flags available to them. Those commands are only interested in a +// subset of the flags anyway. +// In this way, the specification of the CLI is divided into: +// 1. Common args +// 2. Compile related args (ie related to all language targets) +// 3. Language related args (ie related to only that language) +// 4. A superset of all of the above + +let getStatus = + function + | JavaScript + | TypeScript -> "stable" + | Python -> "beta" + | Rust -> "alpha" + | Dart -> "beta" + | Php -> "experimental" + +let getLibPkgVersion = + function + | JavaScript -> Some("npm", "@fable-org/fable-library-js", Literals.JS_LIBRARY_VERSION) + | TypeScript -> Some("npm", "@fable-org/fable-library-ts", Literals.JS_LIBRARY_VERSION) + | Python + | Rust + | Dart + | Php -> None + +/// Common settings for all commands +type ICommonArgs = + abstract version: bool + abstract workingDirectory: string + abstract projPath: string + abstract verbosity: Verbosity + abstract silent: bool + abstract extension: string + abstract language: Language + abstract yes: bool + abstract uncaughtArgs: string list + +/// Common settings all compiling related commands +type ICompilingArgs = + inherit ICommonArgs + abstract definitions: string list + abstract outputDirectory: string option + abstract configuration: string + abstract watch: bool + abstract watchDelay: int + abstract run: string option + abstract runFast: string option + abstract runWatch: string option + abstract noRestore: bool + abstract noCache: bool + abstract exclude: string[] + abstract optimize: bool + abstract legacyCracker: bool + abstract printAst: bool + abstract trimRootModule: bool + abstract fableLib: string option + abstract replace: Map + abstract precompiledLib: string option + abstract noReflection: bool + abstract noParallelTypeCheck: bool + +// Compiling target specific args +type IJavaScriptArgs = + inherit ICompilingArgs + abstract typedArrays: bool + abstract sourceMap: bool + abstract sourceMapRoot: string option + abstract runScript: string option + +type ITypeScriptArgs = + inherit IJavaScriptArgs + +type IPythonArgs = + inherit ICompilingArgs + +type IRustArgs = + inherit ICompilingArgs + +type IPhpArgs = + inherit ICompilingArgs + +type IDartArgs = + inherit ICompilingArgs + +type ICliArgs = + inherit IJavaScriptArgs + inherit ITypeScriptArgs + inherit IPythonArgs + inherit IRustArgs + inherit IPhpArgs + inherit IDartArgs + +open SpectreCoff +open Spectre.Console + +module Output = + let Dim = fun inp -> MarkupD([ Decoration.Dim ], inp) + let dim = Dim >> toMarkedUpString + + let AnsiPanel color = + { + Border = BoxBorder.Ascii + BorderColor = color + Sizing = SizingBehaviour.Collapse + Padding = Padding.AllEqual 1 + } + + let HeaderPanel = AnsiPanel(Some Color.Teal)