###############################################################################
#
# 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 ""} } {
if {[catch {getCSharpTestProgram $name} program]} then {
return false
}
if {[llength $program] < 2} then {
return false
}
unset -nocomplain results errors
if {[catch {
compileCSharp [lindex $program 1] true true true results errors
} code]} then {
return false
}
if {$code ne "Ok"} then {
return false
}
if {[catch {
object invoke [lindex $program 0] Main null
} exitCode]} then {
return false
}
if {$exitCode ne "0"} then {
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 ""
}
#
# 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 ""
}
#
# 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 [file tempname] .dll]]
}
#
# 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
}
}
#
# 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 [file tempname] .cs]
set outputFileName [appendArgs [file tempname] .dll]
#
# 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
}
}
try {
#
# 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}
}
}
#
# 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"}]
}