############################################################################### # # csharp.eagle -- # # Extensible Adaptable Generalized Logic Engine (Eagle) # Eagle CSharp Package File # # Copyright (c) 2007-2012 by Joe Mistachkin. All rights reserved. # # See the file "license.terms" for information on usage and redistribution of # this file, and for a DISCLAIMER OF ALL WARRANTIES. # # RCS: @(#) $Id: $ # ############################################################################### # # NOTE: Use our own namespace here because even though we do not directly # support namespaces ourselves, we do not want to pollute the global # namespace if this script actually ends up being evaluated in Tcl. # namespace eval ::Eagle { # # NOTE: This procedure is used to determine the fully qualified path to the # .NET Core SDK. An empty string will be returned to indicate an # error. This procedure should not raise script errors. # proc getDotNetCoreSdkPath {} { if {[catch {exec -- dotnet --info} info] == 0} then { set info [string map [list \r\n \n] $info] if {[regexp -line -- \ {^\s*Base Path:\s+([^\n]+)$} $info dummy path]} then { return [file normalize $path] } } return "" } # # NOTE: This procedure is used to determine the fully qualified path to the # directory containing the reference assemblies for the .NET Standard # 2.0. An empty string will be returned to indicate an error. This # procedure should not raise script errors. # proc getDotNetStandardReferencePath { {packageVersion ""} {standardVersion netstandard2.0} } { set path [getDotNetCoreSdkPath] if {[string length $path] > 0} then { set libraryDirectory [file normalize [file join \ [file dirname $path] NuGetFallbackFolder netstandard.library]] set buildReferenceSubDirectory [file join build $standardVersion ref] if {[string length $packageVersion] > 0} then { set assemblyDirectory [file normalize [file join \ $libraryDirectory $packageVersion $buildReferenceSubDirectory]] if {[file exists $assemblyDirectory]} then { return $assemblyDirectory } } else { set globPathPattern [file join $libraryDirectory *] set maybeVersions [lsort -decreasing -command [list package vsort] \ [lmap directory [glob -nocomplain -types {d} $globPathPattern] \ { file tail $directory }]] foreach maybeVersion $maybeVersions { set assemblyDirectory [file normalize [file join \ $libraryDirectory $maybeVersion $buildReferenceSubDirectory]] if {[file exists $assemblyDirectory]} then { return $assemblyDirectory } } } } return "" } # # NOTE: This procedure is used to obtain a test program for use with the # C# compiler. Upon success, the return value will be a list with # two elements. The first element will be the name of the C# class # to be compiled. The second element will be the C# program text. # Upon failure, the return value will be an empty list. # proc getCSharpTestProgram { {name ""} } { set prefix Test set id [object invoke Interpreter.GetActive NextId] set className [appendArgs \ $prefix Namespace $id [object invoke Type Delimiter] \ $prefix Class $id] return [list $className [subst { using System; namespace ${prefix}Namespace${id} { public static class ${prefix}Class${id} { public static Int32 Main(String\[\] args) { return 0; } } } }]] } # # NOTE: This procedure is used to determine whether the C# compiler appears # to work when invoked via the interfaces defined in this script file. # Non-zero is returned to indicate success. This procedure should not # raise script errors. # proc doesCompileCSharpWork { {name ""} {errorsVarName ""} } { if {[string length $errorsVarName] > 0} then { upvar 1 $errorsVarName errors } if {[catch {getCSharpTestProgram $name} program]} then { set errors [list [appendArgs \ "caught error while getting \"" $name "\" test program: " \ $program]] return false } if {[llength $program] < 2} then { set errors [list [appendArgs \ "command \[getCSharpTestProgram\] returned malformed \"" \ $name "\" test program: " $program]] return false } unset -nocomplain results local_errors if {[catch { compileCSharp [lindex $program 1] true true true results local_errors } code]} then { set errors [list [appendArgs \ "caught error while compiling \"" $name "\" test program: " \ $code]] return false } if {$code ne "Ok"} then { set errors [list [appendArgs \ "errors from compilation of \"" $name "\" test program: " \ $local_errors]] return false } if {[catch { object invoke [lindex $program 0] Main null } exitCode]} then { set errors [list [appendArgs \ "caught error while executing \"" $name "\" test program: " \ $exitCode]] return false } if {$exitCode ne "0"} then { set errors [list [appendArgs \ "bad exit code from \"" $name "\" test program: " \ $exitCode]] return false } return true } # # NOTE: This procedure is used to determine the command line arguments that # are required to invoke the .NET Core SDK compiler for C#. An empty # list will be returned if the arguments cannot be determined for some # reason -OR- the C# compiler cannot be found. This procedure should # not raise script errors. # proc getDotNetCoreCSharpCommandArgs {} { set path [getDotNetCoreSdkPath] if {[string length $path] > 0} then { set compilerFileName [file normalize [file join \ $path Roslyn bincore csc.dll]] if {[file exists $compilerFileName]} then { return [list dotnet exec [appendArgs \ \" [file nativename $compilerFileName] \"]] } } return [list] } # # NOTE: This procedure is used to format an option to the C# compiler. It # may have a name and/or a value. This procedure should not raise # script errors. # proc formatCompilerArgument { name value } { set wrap "" if {[regexp -- {\s} $name] || [regexp -- {\s} $value]} then { set wrap \" } if {[string length $name] > 0} then { if {[string length $value] > 0} then { return [appendArgs $wrap $name : $value $wrap] } else { return [appendArgs $wrap $name $wrap] } } else { if {[string length $value] > 0} then { return [appendArgs $wrap $value $wrap] } else { return "" } } } # # NOTE: This procedure is used to translate a name/value pair into zero or # more options to the C# compiler. This procedure should not raise # script errors. # proc compilerParameterToArguments { name {value ""} {outputAssemblyVarName ""} } { switch -exact -nocase -- $name { WarningLevel { set name -warn } TreatWarningsAsErrors { set name -warnaserror } OutputAssembly { if {[string length $outputAssemblyVarName] > 0} then { # # HACK: This compiler parameter is handled by our caller; however, # we want to honor the value specified via the OutputAssembly # property. Therefore, reset the specified variable from the # caller to the new value. # upvar 1 $outputAssemblyVarName outputAssembly # # NOTE: Use the file name value specified by the caller verbatim. # set outputAssembly $value # # HACK: Also, make sure that we do not handle this parameter again, # below. # set name ""; set value "" } else { # # BUGBUG: Translate the compiler parameter; however, this may not # actually work, depending on how our caller handles its # output assembly file name. At the time this block was # originally written (2018-04-09), the only caller (i.e. # [compileViaDotNetCoreCSharp]) always passed the output # assembly variable name, making this a non-issue. This # convention should also be followed by future callers of # this procedure. # set name -out } } ReferencedAssemblies.Add { set name -reference if {[file pathtype $value] ne "absolute"} then { set value [file nativename [file normalize \ [file join [getDotNetStandardReferencePath] \ $value]]] } } } set formatted [formatCompilerArgument $name $value] if {[string length $formatted] > 0} then { return [list $formatted] } else { return [list] } } # # NOTE: This procedure is used to obtain the base command line options for # the C# compiler, including those that may be enabled by default. # An empty string may be returned. This procedure should not raise # script errors. # proc getCSharpCompilerOptions { parameters library csharp prefix } { # # NOTE: Make sure that the "standard" preprocessor defines match those # for the platform (i.e. the ones used to compile the Eagle core # library assembly). This caller may disable this handling. # if {$library} then { set libraryOptions [expr { \ [info exists ::eagle_platform(compileOptions)] ? \ $::eagle_platform(compileOptions) : [list] \ }] } else { set libraryOptions [list] } # # NOTE: Permit extra C# compiler options to be passed via the global # array element "csharpOptions", if it exists. This caller may # disable this handling. # if {$csharp} then { set csharpOptions [expr { \ [info exists ::eagle_platform(csharpOptions)] ? \ $::eagle_platform(csharpOptions) : [list] \ }] } else { set csharpOptions [list] } # # NOTE: Start out with no compiler options. # set result "" # # NOTE: Grab the existing compiler options, if any. This caller may # disable this handling (e.g. by specifying an invalid opaque # object handle for the "parameters" argument). # if {[isNonNullObjectHandle $parameters]} then { if {[string length $result] > 0} then { append result " " } append result [$parameters CompilerOptions] } # # NOTE: Are there any Eagle core library options to check? # if {[llength $libraryOptions] > 0} then { # # NOTE: Was the Eagle core library built in the Debug configuration? # if {"DEBUG" in $libraryOptions} then { if {[string length $result] > 0} then { append result " " } append result $prefix define:DEBUG } # # NOTE: Was the Eagle core library built with tracing enabled (i.e. # this allows for use of System.Diagnostics.Trace, etc)? # if {"TRACE" in $libraryOptions} then { if {[string length $result] > 0} then { append result " " } append result $prefix define:TRACE } } # # NOTE: Are there any extra C# compiler options to add? # if {[llength $csharpOptions] > 0} then { # # NOTE: Append the configured extra C# compiler options configured # via the global array element "csharpOptions", if any. # foreach csharpOption $csharpOptions { if {[string length $result] > 0} then { append result " " } append result $csharpOption } } return $result } # # NOTE: This procedure is used to escape all characters in the specified # string for use inside of a regular expression. An empty string # may be returned. This procedure should not raise script errors. # proc regexpEscapeAll { value } { set result "" foreach char [split $value ""] { append result \\u [format %04X [string ordinal $char 0]] } return $result } # # NOTE: This procedure is used to execute the C# compiler and returns its # platform normalized results. # proc runDotNetCSharpCommand { command } { # # NOTE: Evaluate the [exec] command constructed by our caller, in their # context, and return the results, with line-endings normalized. # return [string map [list \r\n \n] [uplevel 1 $command]] } # # NOTE: This procedure is used to extract the C# compiler error messages # from its results. An empty list will be returned if the errors # cannot be determined for some reason. This procedure should not # raise script errors. # proc extractCSharpErrors { fileName results } { set list [list] foreach {dummy match} [regexp -all -line -inline -- \ [appendArgs (^(?: [regexpEscapeAll $fileName] \ {\(\d+,\d+\): )?error CS\d{4}: [^\n]+$)}] $results] { lappend list $match } return $list } # # NOTE: This procedure is used to extract the C# compiler warning messages # from its results. An empty list will be returned if the warnings # cannot be determined for some reason. This procedure should not # raise script errors. # proc extractCSharpWarnings { fileName results } { set list [list] foreach {dummy match} [regexp -all -line -inline -- \ [appendArgs (^(?: [regexpEscapeAll $fileName] \ {\(\d+,\d+\): )?warning CS\d{4}: [^\n]+$)}] $results] { lappend list $match } return $list } # # NOTE: This procedure is used to dynamically compile arbitrary C# code # from within a script using the CSharpCodeProvider class present # in the desktop .NET Framework. It may work on some versions of # Mono as well. This procedure was originally designed to be used # by the test suite; however, it can be quite useful in non-test # scripts as well. # proc compileViaCSharpCodeProvider { string memory symbols strict resultsVarName errorsVarName args } { # # NOTE: The [object] command is required by this procedure. If it # is not available, bail out now. # if {[llength [info commands object]] == 0} then { # # NOTE: We cannot even attempt to compile anything, fail. # set code Error # # NOTE: Prepare to transfer error messages to the caller. # if {[string length $errorsVarName] > 0} then { upvar 1 $errorsVarName local_errors } # # NOTE: Append to the list of errors. # lappend local_errors "cannot compile, missing \"object\" command" # # NOTE: Return the overall result to the caller. # return $code } # # NOTE: Create the C# code provider object (i.e. the compiler). # set provider [object create -alias Microsoft.CSharp.CSharpCodeProvider] # # NOTE: Create the object that provides various parameters to the C# # code provider (i.e. the compiler options). # set parameters [object create -alias \ System.CodeDom.Compiler.CompilerParameters] # # NOTE: Do we not want to persist the generated assembly to disk? # set outputFileName "" if {$memory} then { $parameters GenerateInMemory true } else { $parameters OutputAssembly [set outputFileName [appendArgs [set \ tempName(1) [file tempname]] .dll]] } # # NOTE: Use a try/finally block to cleanup temporary files. # try { # # NOTE: Do we want symbols to be generated for the generated assembly? # if {$symbols} then { $parameters IncludeDebugInformation true } # # NOTE: Start out the compiler options with the pre-existing defaults # for the compiler followed by those necessary for the platform. # $parameters CompilerOptions \ [getCSharpCompilerOptions $parameters true true /] # # NOTE: Process extra compiler settings the caller may have provided. # foreach {name value} $args { $parameters -nocase $name $value } # # NOTE: Prepare to transfer the object reference to the caller. We # must use [upvar] here because otherwise the object is lost # when the procedure call frame is cleaned up. # if {[string length $resultsVarName] > 0} then { upvar 1 $resultsVarName results } # # NOTE: Attempt to compile the specified string as C# and capture the # results into the variable provided by the caller. # set results [$provider -alias CompileAssemblyFromSource $parameters \ $string] # # NOTE: We no longer need the C# code provider object (i.e. the # compiler); therefore, dispose it now. # unset provider; # dispose # # NOTE: Fetch the collection of compiler errors (which may be empty). # set errors [$results -alias Errors] # # NOTE: It is assumed that no assembly was generated if there were # any compiler errors. Ignore all compiler warnings unless # we are in strict mode. # if {[$errors HasErrors] || \ ($strict && [$errors HasWarnings])} then { # # NOTE: Compilation of the assembly failed. # set code Error # # NOTE: Prepare to transfer error messages to the caller. # if {[string length $errorsVarName] > 0} then { upvar 1 $errorsVarName local_errors } # # NOTE: Grab each error object and append the string itself to # the overall list of errors. # for {set index 0} {$index < [$errors Count]} {incr index} { # # NOTE: Get the compiler error object at this index. # set error [$errors -alias Item $index] # # NOTE: Convert it to a string and append it to the list of # errors. # lappend local_errors [$error ToString] # # NOTE: Since the error itself is actually an object, we must # dispose it. # unset error; # dispose } } else { # # NOTE: Compilation of the assembly succeeded. # set code Ok } # # NOTE: We no longer need the compiler errors collection; therefore, # dispose it now. # unset errors; # dispose # # HACK: *BREAKING CHANGE* If there is an output file name, return it # as well; otherwise, just return success. # if {[string length $outputFileName] > 0} then { # # NOTE: Return a two element list: the first element is the overall # result and the second element is the output file name. # return [list $code $outputFileName] } else { # # NOTE: Return the overall result to the caller. # return $code } } finally { # # NOTE: Make sure the dummy temporary files are cleaned up. # if {[array exists tempName]} then { foreach tempFileName [array values tempName] { if {[file exists $tempFileName]} then { catch {file delete $tempFileName} } } } } } # # NOTE: This procedure is used to dynamically compile arbitrary C# code # from within a script using the command line C# compiler provided # by the .NET Core SDK. This procedure was originally designed to # be used by the test suite; however, it can be quite useful in # non-test scripts as well. # proc compileViaDotNetCoreCSharp { string memory symbols strict resultsVarName errorsVarName args } { # # NOTE: Get the initial command line arguments needed to invoke the C# # compiler on .NET Core. If this ends up being invalid, nothing # else can be done. # set command [getDotNetCoreCSharpCommandArgs] if {[llength $command] == 0} then { # # NOTE: We cannot even attempt to compile anything, fail. # set code Error # # NOTE: Prepare to transfer error messages to the caller. # if {[string length $errorsVarName] > 0} then { upvar 1 $errorsVarName local_errors } # # NOTE: Append to the list of errors. # lappend local_errors "cannot compile, C# compiler was not found" # # NOTE: Return the overall result to the caller. # return $code } # # NOTE: Insert the [exec] command before the command line arguments. # The -success option is not used here because we want to handle # errors (only) by processing the compiler output. # set command [linsert $command 0 exec --] # # NOTE: Start out the compiler options with the pre-existing defaults # for the compiler followed by those necessary for the platform. # append command " " [getCSharpCompilerOptions "" true true -] # # NOTE: Allocate a couple temporary file names, one to hold the source # code to compile and one to hold the generated assembly. # set sourceFileName [appendArgs [set tempName(1) [file tempname]] .cs] set outputFileName [appendArgs [set tempName(2) [file tempname]] .dll] # # NOTE: Use a try/finally block to cleanup temporary files. # try { # # NOTE: Process extra compiler settings the caller may have provided. # foreach {name value} $args { set nameValueArguments \ [compilerParameterToArguments $name $value outputFileName] if {[llength $nameValueArguments] > 0} then { eval lappend command $nameValueArguments } } # # NOTE: Make the compiler output a little quieter. This is needed # to maintain compatibility with the results generated by the # [compileViaCSharpCodeProvider] procedure. # lappend command -nologo # # NOTE: Always build as a library so that we do not require a static # Main method. # lappend command -target:library # # NOTE: If symbols are enabled, add the necessary command line # argument. # if {$symbols} then {lappend command -debug} # # NOTE: As of this writing (2018-04-06), the current version of the # .NET Core SDK (2.1.101) uses the "netstandard.dll" assembly # to enable use of the .NET Standard 2.0 library. # lappend command [appendArgs \"-reference: [file nativename \ [file normalize [file join [getDotNetStandardReferencePath] \ netstandard.dll]]] \"] # # NOTE: Set the output assembly file name to the temporary output # file name we obtained from [file tempname] above. # lappend command [appendArgs \"-out: [file nativename [file \ normalize $outputFileName]] \"] # # NOTE: Set the source code file name to the temporary source code # file name we obtained from [file tempname] above. # lappend command [appendArgs \" [file nativename [file normalize \ $sourceFileName]] \"] # # NOTE: First, write the specified string (containing C# code) to # the temporary source code file. # writeFile $sourceFileName $string # # NOTE: Attempt to compile the temporary file as C# and capture the # results into the variable provided by the caller. Since the # results are text, normalize line endings before extracting # the compiler errors and/or warnings. # set local_results [runDotNetCSharpCommand $command] # # NOTE: Extract the compiler errors (which may be empty). # set errors [extractCSharpErrors $sourceFileName $local_results] # # NOTE: Extract the compiler warnings (which may be empty). # set warnings [extractCSharpWarnings $sourceFileName $local_results] # # NOTE: Prepare to transfer the "results" to the caller. # if {[string length $resultsVarName] > 0} then { upvar 1 $resultsVarName results } # # HACK: For backward compatibility with the results generated by # the [compileViaCSharpCodeProvider] procedure, we must now # set the results to an obviously fake opaque object handle # that still matches the normal pattern. # set results System#CodeDom#Compiler#CompilerResults#0 # # NOTE: It is assumed that no assembly was generated if there were # any compiler errors. Ignore all compiler warnings unless # we are in strict mode. # if {[llength $errors] > 0 || \ ($strict && [llength $warnings] > 0)} then { # # NOTE: Compilation of the assembly failed. # set code Error # # NOTE: Prepare to transfer error messages to the caller. # if {[string length $errorsVarName] > 0} then { upvar 1 $errorsVarName local_errors } # # NOTE: If there are compiler errors, add them to the list now. # if {[llength $errors] > 0} then { eval lappend local_errors $errors } # # NOTE: If there are compiler warnings, add them to the list now. # if {[llength $warnings] > 0} then { eval lappend local_errors $warnings } } else { # # NOTE: If the generated assembly was supposed to be loaded into # memory, try to do that now. # if {$memory} then { object load -loadtype File $outputFileName } # # NOTE: Compilation of the assembly succeeded. # set code Ok } } finally { # # NOTE: Delete the temporary file name used to hold the source code. # if {[string length $sourceFileName] > 0 && \ [file exists $sourceFileName]} then { catch {file delete $sourceFileName} } # # NOTE: Make sure the dummy temporary files are cleaned up. # if {[array exists tempName]} then { foreach tempFileName [array values tempName] { if {[file exists $tempFileName]} then { catch {file delete $tempFileName} } } } } # # HACK: *BREAKING CHANGE* If there is an output file name, return it # as well; otherwise, just return success. # if {!$memory && [string length $outputFileName] > 0} then { # # NOTE: Return a two element list: the first element is the overall # result and the second element is the output file name. # return [list $code $outputFileName] } else { # # NOTE: Return the overall result to the caller. # return $code } } # # NOTE: This procedure is used to dynamically compile arbitrary C# code # from within a script. This procedure was originally designed to # be used by the test suite; however, it can be quite useful in # non-test scripts as well. # proc compileCSharp { string memory symbols strict resultsVarName errorsVarName args } { if {[isDotNetCore]} then { return [uplevel 1 [list \ compileViaDotNetCoreCSharp $string $memory $symbols $strict \ $resultsVarName $errorsVarName] $args] } else { return [uplevel 1 [list \ compileViaCSharpCodeProvider $string $memory $symbols $strict \ $resultsVarName $errorsVarName] $args] } } # # NOTE: Provide the Eagle "C#" package to the interpreter. # package provide Eagle.CSharp \ [expr {[isEagle] ? [info engine PatchLevel] : "1.0"}] }