diff --git a/Tamar.Clausewitz/Constructs/Binding.cs b/Tamar.Clausewitz/Constructs/Binding.cs
new file mode 100644
index 0000000..694cfa9
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Binding.cs
@@ -0,0 +1,25 @@
+namespace Tamar.Clausewitz.Constructs;
+
+///
+/// Any statement which includes the assignment operator '=' in Clausewitz.
+/// Including most commands, conditions
+/// and triggers which come in a single line.
+///
+public class Binding : Construct
+{
+ /// Left side.
+ public string Name;
+
+ /// Right side.
+ public string Value;
+
+ /// Primary constructor.
+ /// Parent scope.
+ /// Left side.
+ /// Right side.
+ internal Binding(Scope parent, string name, string value) : base(parent)
+ {
+ Name = name;
+ Value = value;
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Constructs/Construct.cs b/Tamar.Clausewitz/Constructs/Construct.cs
new file mode 100644
index 0000000..4730417
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Construct.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+
+namespace Tamar.Clausewitz.Constructs;
+
+/// Basic Clausewitz language construct.
+public abstract class Construct
+{
+ /// Associated comments.
+ public readonly List Comments = new();
+
+ /// Construct type & parent must be defined when created.
+ /// Parent scope.
+ protected Construct(Scope parent)
+ {
+ Parent = parent;
+ }
+
+ /// Scope's depth level within the containing file.
+ public int Level
+ {
+ get
+ {
+ // This recursive function retrieves the count of all parents up to the root.
+ var parentScopes = 0;
+ var currentScope = Parent;
+ while (true)
+ {
+ if (currentScope == null)
+ return parentScopes;
+ parentScopes++;
+ currentScope = currentScope.Parent;
+ }
+ }
+ }
+
+ /// The parent scope.
+ public Scope Parent { get; internal set; }
+
+ ///
+ /// Extracts pragmas from associated comments within brackets, which are separated
+ /// by commas, and their keywords
+ /// which are separated by spaces.
+ ///
+ public IEnumerable Pragmas
+ {
+ get
+ {
+ var allComments = new List();
+ var @return = new HashSet();
+ if (Comments != null)
+ allComments.AddRange(Comments);
+ if (this is Scope scope)
+ if (scope.EndComments != null)
+ allComments.AddRange(scope.EndComments);
+ if (allComments.Count == 0)
+ return @return;
+ foreach (var comment in allComments)
+ {
+ if (!(comment.Contains('[') && comment.Contains(']')))
+ continue;
+
+ // All pragmas are guaranteed to be lower case and trimmed.
+ var pragmas = Regex.Replace(comment.Split('[', ']')[1], @"\s+", " ").ToLower().Split(',');
+ foreach (var pragma in pragmas)
+ @return.Add(new Pragma(pragma.Split(' ')));
+ }
+
+ return @return;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Constructs/Extensions.cs b/Tamar.Clausewitz/Constructs/Extensions.cs
new file mode 100644
index 0000000..41d6ea3
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Extensions.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+
+namespace Tamar.Clausewitz.Constructs;
+
+///
+/// Extension class for all language constructs.
+///
+public static class Extensions
+{
+ ///
+ /// Checks if a collection of pragmas has the requested pragma with the specified
+ /// keywords.
+ ///
+ /// Extended.
+ /// Keywords (all keywords).
+ /// Returns true if a pragma with the said keywords was found.
+ public static bool Contains(this IEnumerable pragmas, params string[] keywords)
+ {
+ foreach (var pragma in pragmas)
+ if (pragma.Contains(keywords))
+ return true;
+ return false;
+ }
+
+ ///
+ /// Formats a keyword to lower case and trims it.
+ ///
+ /// Extended.
+ /// Formatted
+ internal static string FormatKeyword(this string keyword)
+ {
+ return keyword.Trim().ToLower();
+ }
+
+ ///
+ /// Formats all keywords with lazy evaluation.
+ ///
+ /// Extended.
+ /// Formatted.
+ internal static IEnumerable FormatKeywords(this IEnumerable keywords)
+ {
+ foreach (var keyword in keywords)
+ yield return keyword.FormatKeyword();
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Constructs/Pragma.cs b/Tamar.Clausewitz/Constructs/Pragma.cs
new file mode 100644
index 0000000..198da31
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Pragma.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+
+namespace Tamar.Clausewitz.Constructs;
+
+///
+/// Every Clausewitz construct may have pragmas within the associated comments.
+/// Each pragma includes a set of
+/// keywords.
+///
+public struct Pragma
+{
+ /// User-friendly constructor.
+ /// Keywords.
+ public Pragma(params string[] keywords)
+ {
+ Keywords = new HashSet(keywords);
+ }
+
+ /// Primary constructor.
+ /// Keywords.
+ public Pragma(HashSet keywords)
+ {
+ Keywords = keywords;
+ }
+
+ /// Checks if a pragma has all of the specified keywords.
+ /// Keywords.
+ /// Boolean.
+ public bool Contains(IEnumerable keywords)
+ {
+ return Keywords.IsSupersetOf(keywords.FormatKeywords());
+ }
+
+ /// Checks if a pragma has all of the specified keywords.
+ /// Keywords.
+ /// Boolean.
+ public bool Contains(params string[] keywords)
+ {
+ return Keywords.IsSupersetOf(keywords.FormatKeywords());
+ }
+
+ ///
+ /// Keywords are separated by spaces within each pragma, and their order does not
+ /// matter.
+ ///
+ public readonly HashSet Keywords;
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Constructs/Scope.cs b/Tamar.Clausewitz/Constructs/Scope.cs
new file mode 100644
index 0000000..2e7d1e1
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Scope.cs
@@ -0,0 +1,220 @@
+using System.Collections.Generic;
+
+namespace Tamar.Clausewitz.Constructs;
+
+/// Scopes are files, directories, and clauses.
+public class Scope : Construct
+{
+ /// Child members.
+ public readonly List Members = new();
+
+ /// Comments located at the end of the scope.
+ public List EndComments = new();
+
+ ///
+ /// Special scope constructor for files (which has no parent scope, but parent
+ /// directory).
+ ///
+ /// Scope name.
+ protected Scope(string name) : this(null, name)
+ {
+ // implemented at primary constructor.
+ }
+
+ /// Primary constructor.
+ /// Parent scope.
+ /// Optional name.
+ private Scope(Scope parent, string name = null) : base(parent)
+ {
+ if (name != null)
+ Name = name;
+ }
+
+ /// If false, all members within this scope will come in a single line.
+ public bool Indented
+ {
+ get
+ {
+ if (Pragmas.Contains("indent"))
+ return true;
+ if (Pragmas.Contains("unindent"))
+ return false;
+ if (IndentedParent(Parent))
+ return true;
+ if (UnindentedParent(Parent))
+ return false;
+ if (AllTokens() && Members.Count > 20)
+ return false;
+ return true;
+
+ bool IndentedParent(Scope parent)
+ {
+ while (true)
+ {
+ if (parent == null)
+ return false;
+ if (parent.Pragmas.Contains("indent", "all"))
+ return true;
+ parent = parent.Parent;
+ }
+ }
+
+ bool UnindentedParent(Scope parent)
+ {
+ while (true)
+ {
+ if (parent == null)
+ return false;
+ if (parent.Pragmas.Contains("unindent", "all"))
+ return true;
+ parent = parent.Parent;
+ }
+ }
+
+ bool AllTokens()
+ {
+ foreach (var member in Members)
+ if (!(member is Token))
+ return false;
+ return true;
+ }
+ }
+ }
+
+ /// Optional scope name (not all scopes have names in Clausewitz)
+ public string Name { get; set; }
+
+ /// If true, all members within this scope will be sorted alphabetically.
+ public bool Sorted
+ {
+ get
+ {
+ return SortedParent(Parent) || Pragmas.Contains("sort");
+
+ bool SortedParent(Scope parent)
+ {
+ while (true)
+ {
+ if (parent == null)
+ return false;
+ if (parent.Pragmas.Contains("sort", "all"))
+ return true;
+ parent = parent.Parent;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Creates a new binding within this scope. (Automatically assigns the
+ /// parent)
+ ///
+ /// Left side.
+ /// Right side.
+ /// New binding.
+ public Binding NewBinding(string name, string value)
+ {
+ var binding = new Binding(this, name, value);
+ Members.Add(binding);
+ return binding;
+ }
+
+ ///
+ /// Creates a new scope within this scope. (Automatically assigns the
+ /// parent)
+ ///
+ /// Optional name.
+ /// New scope.
+ public Scope NewScope(string name = null)
+ {
+ var scope = new Scope(this, name);
+ Members.Add(scope);
+ return scope;
+ }
+
+ ///
+ /// Creates a new token within this scope. (Automatically assigns the
+ /// parent)
+ ///
+ /// Token symbol/string/value.
+ /// New token.
+ public Token NewToken(string value)
+ {
+ var token = new Token(this, value);
+ Members.Add(token);
+ return token;
+ }
+
+ ///
+ /// Sorts members alphabetically.
+ ///
+ public void Sort()
+ {
+ Members.Sort((first, second) =>
+ {
+ var constructs = new[]
+ {
+ first,
+ second
+ };
+ var values = new string[2];
+ for (var index = 0; index < constructs.Length; index++)
+ {
+ var construct = constructs[index];
+ switch (construct)
+ {
+ case Binding binding:
+ values[index] = binding.Name;
+ break;
+ case Scope scope:
+ values[index] = scope.Name;
+ break;
+ case Token token:
+ values[index] = token.Value;
+ break;
+ }
+ }
+
+ return string.CompareOrdinal(values[0], values[1]);
+ });
+ }
+
+ ///
+ /// Finds recursively a scope by its exact name.
+ ///
+ public Scope FindScope(string name)
+ {
+ foreach (var member in Members)
+ if (member is Scope scope)
+ if (scope.Name == name)
+ return scope;
+ else
+ {
+ var found = scope.FindScope(name);
+ if (found != null)
+ return found;
+ }
+ return null;
+ }
+ ///
+ /// Finds a binding in this scope by its exact name.
+ ///
+ public Binding FindBinding(string name)
+ {
+ foreach (var member in Members)
+ if (member is Binding binding && binding.Name == name)
+ return binding;
+ return null;
+ }
+
+ ///
+ /// Returns true if the scope contains the given token.
+ ///
+ public bool HasToken(string value)
+ {
+ foreach(var member in Members)
+ if (member is Token token && token.Value == value)
+ return true;
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Constructs/Token.cs b/Tamar.Clausewitz/Constructs/Token.cs
new file mode 100644
index 0000000..b6ce226
--- /dev/null
+++ b/Tamar.Clausewitz/Constructs/Token.cs
@@ -0,0 +1,16 @@
+namespace Tamar.Clausewitz.Constructs;
+
+/// A single token, sometimes a number, a string or just a symbol.
+public class Token : Construct
+{
+ /// The actual symbol/value of the token.
+ public string Value;
+
+ /// Primary constructor.
+ /// Parent scope.
+ /// The token itself.
+ internal Token(Scope parent, string value) : base(parent)
+ {
+ Value = value;
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/IO/Directory.cs b/Tamar.Clausewitz/IO/Directory.cs
new file mode 100644
index 0000000..5737276
--- /dev/null
+++ b/Tamar.Clausewitz/IO/Directory.cs
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+
+namespace Tamar.Clausewitz.IO;
+
+///
+/// Corresponds to a file directory. (Renamed from 'Directory' to 'Folder' due to
+/// name conflicts with
+/// 'System.IO.Directory', Then renamed again back to 'Directory' after using an
+/// alias names for the .NET static
+/// classes.
+///
+public class Directory : IExplorable
+{
+ /// Sub-directories
+ public readonly List Directories = new();
+
+ /// Files.
+ public readonly List Files = new();
+
+ /// Primary constructor
+ /// Parent directory.
+ /// Directory name.
+ internal Directory(Directory parent, string name)
+ {
+ Name = name;
+ Parent = parent;
+ }
+
+ ///
+ /// Returns all directories and then all files within this directory in a single
+ /// list.
+ ///
+ public IEnumerable Explorables
+ {
+ get
+ {
+ var explorables = new List();
+ explorables.AddRange(Directories);
+ explorables.AddRange(Files);
+ return explorables;
+ }
+ }
+
+ ///
+ /// Returns true if this directory has no parent. (Typically "C:\")
+ ///
+ public bool IsRoot => Parent == null;
+
+ ///
+ public string Address => this.GetAddress();
+
+ /// Directory name.
+ public string Name { get; set; }
+
+ ///
+ public Directory Parent { get; internal set; }
+
+ ///
+ /// Creates a new directory within this directory. (Automatically assigns the
+ /// parent)
+ ///
+ /// Directory name.
+ /// New directory.
+ public Directory NewDirectory(string name)
+ {
+ var directory = new Directory(this, name);
+ Directories.Add(directory);
+ return directory;
+ }
+
+ ///
+ /// Creates a new file within this directory. (Automatically assigns the
+ /// parent)
+ ///
+ /// File name with extension
+ /// New file.
+ public FileScope NewFile(string name)
+ {
+ var file = new FileScope(this, name);
+ Files.Add(file);
+ return file;
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/IO/Extensions.cs b/Tamar.Clausewitz/IO/Extensions.cs
new file mode 100644
index 0000000..c0dec45
--- /dev/null
+++ b/Tamar.Clausewitz/IO/Extensions.cs
@@ -0,0 +1,119 @@
+using System;
+using System.IO;
+using System.Linq;
+
+// ReSharper disable UnusedMember.Global
+
+namespace Tamar.Clausewitz.IO;
+
+///
+/// Extension class for various interfaces. These extensions are used only within
+/// this library to implement a
+/// similar pattern to Multiple-Inheritances in C#. Implement corresponding
+/// properties within the derived classes of
+/// these interfaces that call these extensions.
+///
+internal static class Extensions
+{
+ ///
+ /// Retrieves the parent directory connected to all grandparents of parents without
+ /// any sibling entries.
+ ///
+ /// Extended.
+ /// Closest parent.
+ public static Directory DefineParents(this string address)
+ {
+ if (!Path.IsPathFullyQualified(address))
+ address = Path.Combine(Environment.CurrentDirectory, address);
+
+ var root = new Directory(null, Path.GetPathRoot(address));
+ var parent = root;
+ var remaining = address.Remove(0, root.Address.Length);
+ while (remaining.Contains(Path.DirectorySeparatorChar))
+ {
+ var name = remaining.Substring(0, remaining.IndexOf(Path.DirectorySeparatorChar));
+ remaining = remaining.Remove(0, name.Length + 1);
+ parent = parent.NewDirectory(name);
+ }
+
+ return parent;
+ }
+
+ ///
+ /// Checks if the the given directory is included somewhere within the extended
+ /// directory.
+ ///
+ /// Extended.
+ /// Suspected parent.
+ /// True if included.
+ public static bool IsSubDirectoryOf(this string candidate, string parent)
+ {
+ var isChild = false;
+ var candidateInfo = new DirectoryInfo(candidate);
+ var parentInfo = new DirectoryInfo(parent);
+
+ while (candidateInfo.Parent != null)
+ if (candidateInfo.Parent.FullName == parentInfo.FullName)
+ {
+ isChild = true;
+ break;
+ }
+ else
+ {
+ candidateInfo = candidateInfo.Parent;
+ }
+
+ return isChild;
+ }
+
+ /// Retrieves the full address.
+ /// Extended.
+ /// Full address.
+ internal static string GetAddress(this IExplorable explorable)
+ {
+ var address = string.Empty;
+ var currentExplorable = explorable;
+ while (true)
+ {
+ if (currentExplorable == null)
+ return address;
+ if (currentExplorable.GetType() == typeof(Directory))
+ address = Path.Combine(currentExplorable.Name, address);
+ else
+ address = currentExplorable.Name;
+ currentExplorable = currentExplorable.Parent;
+ }
+ }
+
+ ///
+ /// Ensures the address is fully qualified using the correct directory and drive
+ /// separators for each platform.
+ ///
+ /// Relative or fully qualified path.
+ internal static string ToFullyQualifiedAddress(this string address)
+ {
+ // Replace slashes with platform specific separators.
+ if (Environment.OSVersion.Platform == PlatformID.Unix)
+ address = address.Replace('\\', Path.DirectorySeparatorChar);
+ if (Environment.OSVersion.Platform == PlatformID.Win32NT)
+ {
+ address = address.Replace('/', Path.DirectorySeparatorChar);
+ }
+ else
+ {
+ address = address.Replace('/', Path.DirectorySeparatorChar);
+ address = address.Replace('\\', Path.DirectorySeparatorChar);
+ }
+
+ // Replace pairs of separators with single ones:
+ var separatorPair = string.Empty + Path.DirectorySeparatorChar + Path.DirectorySeparatorChar;
+ while (address.Contains(separatorPair))
+ address = address.Replace(separatorPair,
+ string.Empty + Path.DirectorySeparatorChar);
+
+ // This checks whether the address is local or full:
+ if (!Path.IsPathFullyQualified(address))
+ address = Environment.CurrentDirectory + Path.DirectorySeparatorChar + address;
+ return address;
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/IO/FileScope.cs b/Tamar.Clausewitz/IO/FileScope.cs
new file mode 100644
index 0000000..4d820d2
--- /dev/null
+++ b/Tamar.Clausewitz/IO/FileScope.cs
@@ -0,0 +1,45 @@
+using Tamar.Clausewitz.Constructs;
+
+namespace Tamar.Clausewitz.IO;
+
+///
+/// An extended type of scope, which enforces file name and parent
+/// directory.
+///
+public class FileScope : Scope, IExplorable
+{
+ /// Primary constructor.
+ /// Parent directory.
+ /// File name with extension.
+ internal FileScope(Directory parent, string name) : base(name)
+ {
+ Name = name;
+ Parent = parent;
+ }
+
+ ///
+ public string Address => this.GetAddress();
+
+ ///
+ public new Directory Parent { get; internal set; }
+
+ ///
+ /// Retrieves all text within this file.
+ ///
+ /// File contents.
+ public string ReadText()
+ {
+ return System.IO.File.ReadAllText(Address);
+ }
+
+ ///
+ /// Writes the given text into this file.
+ ///
+ ///
+ internal void WriteText(string data)
+ {
+ if (!System.IO.Directory.Exists(Parent.Address))
+ System.IO.Directory.CreateDirectory(Parent.Address);
+ System.IO.File.WriteAllText(Address, data);
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/IO/IExplorable.cs b/Tamar.Clausewitz/IO/IExplorable.cs
new file mode 100644
index 0000000..583ba39
--- /dev/null
+++ b/Tamar.Clausewitz/IO/IExplorable.cs
@@ -0,0 +1,14 @@
+namespace Tamar.Clausewitz.IO;
+
+/// Scopes which can be explored through a file manager.
+public interface IExplorable
+{
+ /// Full address.
+ string Address { get; }
+
+ /// Name.
+ string Name { get; }
+
+ /// Parent directory.
+ Directory Parent { get; }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Interpreter.cs b/Tamar.Clausewitz/Interpreter.cs
new file mode 100644
index 0000000..6376d45
--- /dev/null
+++ b/Tamar.Clausewitz/Interpreter.cs
@@ -0,0 +1,617 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Tamar.Clausewitz.Constructs;
+using Tamar.Clausewitz.IO;
+using Directory = Tamar.Clausewitz.IO.Directory;
+
+// ReSharper disable UnusedMember.Global
+
+namespace Tamar.Clausewitz;
+
+/// The Clausewitz interpreter.
+public static class Interpreter
+{
+ ///
+ /// Regex rule for valid Clausewitz values. Includes: identifiers, numerical
+ /// values, and ':' variable binding
+ /// operator.
+ ///
+ private const string ValueRegexRule = @"[a-zA-Z0-9_.:""]+";
+
+ /// Reads a directory in the given address.
+ /// Relative or fully qualified path.
+ /// Explorable directory.
+ public static Directory ReadDirectory(string address)
+ {
+ // This checks whether the address is local or full:
+ address = address.ToFullyQualifiedAddress();
+
+ // Interpret all files and directories found within this directory:
+ var directory = address.DefineParents().NewDirectory(Path.GetFileName(address));
+
+ // If doesn't exist, notify an error.
+ if (!System.IO.Directory.Exists(address))
+ {
+ Log.SendError("Could not locate the directory.", address);
+ return null;
+ }
+
+ // Read the directory:
+ ReadAll(directory);
+ return directory;
+ }
+
+ /// Reads a file in the given address.
+ /// Relative or fully qualified path.
+ /// File scope.
+ public static FileScope ReadFile(string address)
+ {
+ // This checks whether the address is local or full:
+ address = address.ToFullyQualifiedAddress();
+
+ // Read the file:
+ var file = address.DefineParents().NewFile(Path.GetFileName(address));
+ return TryInterpret(file);
+ }
+
+ ///
+ /// Translates data back into Clausewitz syntax and writes down to the
+ /// actual file.
+ ///
+ /// Extended.
+ public static void Write(this FileScope fileScope)
+ {
+ fileScope.WriteText(Translate(fileScope));
+ Log.Send("File saved: \"" + Path.GetFileName(fileScope.Address) + "\".");
+ }
+
+ ///
+ /// Translates data back into Clausewitz syntax and writes down all files within
+ /// this directory.
+ ///
+ /// Extended
+ public static void Write(this Directory directory)
+ {
+ foreach (var file in directory.Files)
+ file.Write();
+ foreach (var subDirectory in directory.Directories)
+ subDirectory.Write();
+ }
+
+ ///
+ /// Checks if a token is a valid value in Clausewitz syntax standards for both
+ /// names & values.
+ ///
+ /// Token.
+ /// Boolean.
+ internal static bool IsValidValue(string token)
+ {
+ return Regex.IsMatch(token, @"\d") || token == "---" || Regex.IsMatch(token, ValueRegexRule);
+ }
+
+ /// Tokenizes a file.
+ /// Clausewitz file.
+ /// Token list.
+ internal static List<(string token, int line)> Tokenize(FileScope fileScope)
+ {
+ // The actual text data, character by character.
+ var data = fileScope.ReadText();
+
+ // The current token so far recorded since the last token-breaking character.
+ var token = string.Empty;
+
+ // All tokenized tokens within this file so far.
+ var tokens = new List<(string token, int line)>();
+
+ // Indicates a delimited string token.
+ var @string = false;
+
+ // Indicates a delimited comment token.
+ var comment = false;
+
+ // Indicates a new line.
+ var newline = false;
+
+ // Counts each newline.
+ var line = 1;
+
+ // Keeps track of the previous char.
+ var prevChar = '\0';
+
+ // Tokenization loop:
+ foreach (var @char in data)
+ {
+ // Count a new line.
+ if (newline)
+ {
+ line++;
+ newline = false;
+ }
+
+ // Keep tokenizing a string unless a switching delimiter comes outside escape.
+ if (@string && !(@char == '"' && prevChar != '\\'))
+ goto concat;
+
+ // Keep tokenizing a comment unless a switching delimiter comes.
+ if (comment && !(@char == '\r' || @char == '\n'))
+ goto concat;
+
+ // Standard tokenizer:
+ var charToken = '\0';
+ switch (@char)
+ {
+ // Newline: (also comment delimiter)
+ case '\r':
+ case '\n':
+
+ // Switch comments:
+ if (comment)
+ {
+ comment = false;
+
+ // Add empty comments:
+ if (token.Length == 0)
+ tokens.Add((string.Empty, line));
+ }
+
+ // Cross-platform compatibility for newlines:
+ if (prevChar == '\r' && @char == '\n')
+ break;
+ newline = true;
+ break;
+
+ // Whitespace (which breaks tokens):
+ case ' ':
+ case '\t':
+ break;
+
+ // String delimiter:
+ case '"':
+ @string = !@string;
+ token += @char;
+ break;
+
+ // Comment delimiter:
+ case '#':
+ comment = true;
+ charToken = @char;
+ break;
+
+ // Scope clauses & binding operator:
+ case '}':
+ case '{':
+ case '=':
+ charToken = @char;
+ break;
+
+ // Any other character:
+ default:
+ goto concat;
+ }
+
+ // Add new tokens to the list:
+ if (token.Length > 0 && !@string)
+ {
+ tokens.Add((token, line));
+ token = string.Empty;
+ }
+
+ if (charToken != '\0')
+ tokens.Add((new string(charToken, 1), line));
+ prevChar = @char;
+ continue;
+
+ // Concat characters to unfinished numbers/words/comments/strings.
+ concat:
+ token += @char;
+ prevChar = @char;
+ }
+
+ // EOF & last token:
+ if (token.Length > 0 && !@string)
+ tokens.Add((token, line));
+ return tokens;
+ }
+
+ /// Translates data back into Clausewitz syntax.
+ /// Root scope (file scope)
+ /// Clausewitz script.
+ internal static string Translate(Scope root)
+ {
+ var data = string.Empty;
+ var newline = Environment.NewLine;
+ var tabs = new string('\t', root.Level);
+
+ // Files include their own comments at the beginning followed by an empty line.
+ if (root is FileScope)
+ if (root.Comments.Count > 0)
+ {
+ foreach (var comment in root.Comments)
+ data += tabs + "# " + comment + newline;
+ data += newline;
+ }
+
+ // Translate scope members:
+ foreach (var construct in root.Members)
+ {
+ foreach (var comment in construct.Comments)
+ data += tabs + "# " + comment + newline;
+
+ // Translate the actual type:
+ switch (construct)
+ {
+ case Scope scope:
+ if (string.IsNullOrWhiteSpace(scope.Name))
+ data += tabs + '{';
+ else
+ data += tabs + scope.Name + " = {";
+ if (scope.Members.Count > 0)
+ {
+ data += newline + Translate(scope);
+ foreach (var comment in scope.EndComments)
+ data += tabs + "\t" + "# " + comment + newline;
+ data += tabs + '}' + newline;
+ }
+ else
+ {
+ data += '}' + newline;
+ }
+
+ break;
+ case Binding binding:
+ data += tabs + binding.Name + " = " + binding.Value + newline;
+ break;
+ case Token token:
+ if (root.Indented)
+ {
+ data += tabs + token.Value + newline;
+ }
+ else
+ {
+ var preceding = " ";
+ var following = string.Empty;
+
+ // Preceding characters:
+ if (root.Members.First() == token)
+ preceding = tabs;
+ else if (token.Comments.Count > 0)
+ preceding = tabs;
+ else if (root.Members.First() != token)
+ if (!(root.Members[root.Members.IndexOf(token) - 1] is Token))
+ preceding = tabs;
+
+ // Following characters:
+ if (root.Members.Last() != token)
+ {
+ var next = root.Members[root.Members.IndexOf(token) + 1];
+ if (!(next is Token))
+ following = newline;
+ if (next.Comments.Count > 0)
+ following = newline;
+ }
+ else if (root.Members.Last() == token)
+ {
+ following = newline;
+ }
+
+ data += preceding + token.Value + following;
+ }
+
+ break;
+ }
+ }
+
+ // Append end comments at files:
+ if (root is FileScope)
+ foreach (var comment in root.EndComments)
+ data += newline + "# " + comment;
+ return data;
+ }
+
+ /// Interprets a file and all of its inner scopes recursively.
+ /// A Clausewitz file.
+ ///
+ /// A syntax error was encountered during
+ /// interpretation.
+ ///
+ private static void Interpret(FileScope fileScope)
+ {
+ // Tokenize the file:
+ var tokens = Tokenize(fileScope);
+
+ // All associated comments so far.
+ var comments = new List<(string text, int line)>();
+
+ // Current scope.
+ Scope scope = fileScope;
+
+ // Interpretation loop:
+ for (var index = 0; index < tokens.Count; index++)
+ {
+ // All current information:
+ var token = tokens[index].token;
+ var nextToken = index < tokens.Count - 1 ? tokens[index + 1].token : string.Empty;
+ var prevToken = index > 0 ? tokens[index - 1].token : string.Empty;
+ var prevPrevToken = index > 1 ? tokens[index - 2].token : string.Empty;
+ var line = tokens[index].line;
+
+ // Interpret tokens:
+ // Enter a new scope:
+ if (token == "{" && prevToken != "#")
+ {
+ // Participants:
+ var name = prevPrevToken;
+ var binding = prevToken;
+
+ // Syntax check:
+ if (binding == "=")
+ {
+ if (IsValidValue(name))
+ scope = scope.NewScope(name);
+ else
+ throw new SyntaxException("Invalid name at scope binding.", fileScope, line, token);
+ }
+ else
+ {
+ scope = scope.NewScope();
+ }
+
+ AssociateComments(scope);
+ }
+ // Exit the current scope:
+ else if (token == "}" && prevToken != "#")
+ {
+ // Associate end comments:
+ AssociateComments(scope, true);
+
+ // Check if the current scope is the file, if so, then notify an error of a missing opening "{".
+ if (!(scope is FileScope))
+ {
+ if (scope.Sorted)
+ scope.Sort();
+ scope = scope.Parent;
+ }
+ else
+ {
+ throw new SyntaxException("Missing an opening '{' for a scope", fileScope, line,
+ token);
+ }
+ }
+ // Binding operator:
+ else if (token == "=" && prevToken != "#")
+ {
+ // Participants:
+ var name = prevToken;
+ var value = nextToken;
+
+ // Skip scope binding: (handled at "{" case, otherwise will claim as a syntax error.)
+ if (value == "{")
+ continue;
+
+ // Syntax check:
+ if (!IsValidValue(name))
+ throw new SyntaxException("Invalid name at binding.", fileScope, line, token);
+ if (!IsValidValue(value))
+ throw new SyntaxException("Invalid value at binding.", fileScope, line, token);
+ scope.NewBinding(name, value);
+ AssociateComments();
+ }
+ // Comment/pragma:
+ else if (token == "#")
+ {
+ // Attached means the comment comes at the same line with another language construct:
+ // If the comment comes at the same line with another construct, then it will be associated to that construct.
+ // If the comment takes a whole line then it will be stacked and associated with the next construct when it is created.
+ // If there was an empty line after the comment at the beginning of the file, then it will be associated with the file itself.
+ // Comments are responsible for pragmas as well when utilizing square brackets.
+ var lineOfPrevToken = index > 0 ? tokens[index - 1].line : -1;
+ var isAttached = line == lineOfPrevToken;
+
+ // Associate attached comments HERE:
+ if (isAttached)
+ {
+ if (prevToken != "{")
+ scope.Members.Last().Comments.Add(nextToken.Trim());
+ else
+ scope.Comments.Add(nextToken.Trim());
+ }
+ else
+ {
+ comments.Add((nextToken.Trim(), line));
+ }
+ }
+ // Unattached value/word token:
+ else
+ {
+ // Check if bound:
+ var isBound = prevToken.Contains('=') || nextToken.Contains('=');
+
+ // Check if commented:
+ var isComment = prevToken.Contains('#');
+
+ // Skip those cases:
+ if (!isBound && !isComment)
+ {
+ if (IsValidValue(token))
+ {
+ scope.NewToken(token);
+ AssociateComments();
+ }
+ else
+ {
+ throw new SyntaxException("Unexpected token.", fileScope, line, token);
+ }
+ }
+ }
+ }
+
+ // Missing a closing "{" for scopes above the file level:
+ if (scope != fileScope)
+ throw new SyntaxException("Missing a closing '}' for a scope.", fileScope, tokens.Last().line,
+ tokens.Last().token);
+
+ // Associate end-comments (of the file):
+ AssociateComments(scope, true);
+
+ // This local method helps with associating the stacking comments with the latest language construct.
+ void AssociateComments(Construct construct = null, bool endComments = false)
+ {
+ // No comments, exit.
+ if (comments.Count == 0)
+ return;
+
+ // Associate with last construct if parameter is null.
+ // ReSharper disable once ConvertIfStatementToSwitchStatement
+ if (construct == null && scope.Members.Count == 0)
+ return;
+ if (construct == null)
+ construct = scope.Members.Last();
+
+ // Leading comments at the beginning of a file:
+ if (!endComments && construct.Parent is FileScope && construct.Parent.Members.First() == construct)
+ {
+ var associatedWithFile = new List();
+ var associatedWithConstruct = new List();
+ var associateWithFile = false;
+
+ // Reverse iteration:
+ for (var index = comments.Count - 1; index >= 0; index--)
+ {
+ if (!associateWithFile)
+ {
+ var prevCommentLine = index < comments.Count - 1 ? comments[index + 1].line : -1;
+ var commentLine = comments[index].line;
+ if (prevCommentLine > 1 && prevCommentLine - commentLine != 1)
+ associateWithFile = true;
+ }
+
+ if (associateWithFile)
+ associatedWithFile.Add(comments[index].text);
+ else
+ associatedWithConstruct.Add(comments[index].text);
+ }
+
+ // Reverse & append:
+ construct.Parent.Comments.AddRange(associatedWithFile.Reverse());
+ construct.Comments.AddRange(associatedWithConstruct.Reverse());
+ }
+ else if (!endComments)
+ {
+ foreach (var comment in comments)
+ construct.Comments.Add(comment.text);
+ }
+
+ else if (construct is Scope commentScope)
+ {
+ foreach (var comment in comments)
+ commentScope.EndComments.Add(comment.text);
+ }
+
+ comments.Clear();
+ }
+ }
+
+ ///
+ /// Reads & interprets all files or data found in the given address. It will
+ /// attempt to load & interpret each
+ /// file, however if an error has occurred it will skip the specific files.
+ ///
+ /// Parent directory.
+ private static void ReadAll(Directory parent)
+ {
+ // Read files:
+ foreach (var file in System.IO.Directory.GetFiles(parent.Address))
+ if (file.EndsWith(".txt", StringComparison.OrdinalIgnoreCase))
+ {
+ var newFile = parent.NewFile(Path.GetFileName(file));
+ var interpretedFile = TryInterpret(newFile);
+ if (interpretedFile == null)
+ parent.Files.Remove(newFile);
+ }
+
+ // Read Directories:
+ foreach (var directory in System.IO.Directory.GetDirectories(parent.Address))
+ ReadAll(parent.NewDirectory(Path.GetFileNameWithoutExtension(directory)));
+ }
+
+ ///
+ /// This method will try to interpret a file and handle potential syntax
+ /// exceptions. If something went wrong
+ /// during the interpretation it will rather not load the file at all, the user
+ /// will be notified through an error
+ /// message in the log, and the application will continue to run routinely.
+ ///
+ /// Clausewitz file.
+ /// Interpreted file or null if an error occurred.
+ private static FileScope TryInterpret(FileScope fileScope)
+ {
+ if (!File.Exists(fileScope.Address))
+ {
+ Log.SendError("Could not locate the file.", fileScope.Address);
+ return null;
+ }
+
+ try
+ {
+ Interpret(fileScope);
+ Log.Send("Loaded file: \"" + Path.GetFileName(fileScope.Address) + "\".");
+ return fileScope;
+ }
+ catch (SyntaxException syntaxException)
+ {
+ syntaxException.Send();
+ Log.Send("File was not loaded: \"" + Path.GetFileName(fileScope.Address) + "\".");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Creates a new empty Clausewitz file.
+ ///
+ /// Relative or fully qualified path.
+ /// The newly created file.
+ public static FileScope NewFile(string address)
+ {
+ // This checks whether the address is local or full:
+ address = address.ToFullyQualifiedAddress();
+
+ // Read the file:
+ return address.DefineParents().NewFile(Path.GetFileName(address));
+ }
+
+ ///
+ /// Thrown when syntax-related errors occur during interpretation time which result
+ /// in a broken & meaningless interpretation.
+ ///
+ public class SyntaxException : Exception
+ {
+ /// The file where the exception occurred.
+ public readonly FileScope FileScope;
+
+ /// The line at which the exception occurred.
+ public readonly int Line;
+
+ /// The token responsible for the exception.
+ public readonly string Token;
+
+ /// Primary constructor.
+ /// Message.
+ /// The file where the exception occurred.
+ /// The line at which the exception occurred.
+ /// The token responsible for the exception.
+ internal SyntaxException(string message, FileScope fileScope, int line, string token) : base(message)
+ {
+ FileScope = fileScope;
+ Line = line;
+ Token = token;
+ }
+
+ /// Retrieves all detailed information in a formatted string.
+ public string Details =>
+ $"Token: '{Token}'\nLine: {Line}\nFile: {FileScope.Address.Remove(0, Environment.CurrentDirectory.Length)}";
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Log.cs b/Tamar.Clausewitz/Log.cs
new file mode 100644
index 0000000..f2009f5
--- /dev/null
+++ b/Tamar.Clausewitz/Log.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+
+namespace Tamar.Clausewitz;
+
+///
+/// Logs messages during operation time for the Clausewitz interpreter. Note that
+/// this class does not write a log
+/// file nor notify the user (or the developer) for any messages. Use the event
+/// handler to keep track of the messages,
+/// or write down to file the entire messages list.
+///
+public static class Log
+{
+ /// Special delegate to deliver the message as an event argument.
+ /// The message that was sent.
+ public delegate void MessageHandler(Message message);
+
+ /// Contains all messages.
+ public static readonly List Messages = new();
+
+ /// Sends a new message to the log.
+ /// Main text line.
+ /// More details.
+ public static void Send(string text, string details = "")
+ {
+ Send(new Message(Message.Types.Info, text, details));
+ }
+
+ ///
+ /// Sends an exception to the log.
+ ///
+ /// Extended.
+ public static void Send(this Exception exception)
+ {
+ var details = string.Empty;
+ if (exception is Interpreter.SyntaxException syntaxException)
+ details = syntaxException.Details;
+ var message = new Message(Message.Types.Error, exception.Message, details, exception);
+ Send(message);
+ }
+
+ /// Sends a new message to the log.
+ /// Log message.
+ public static void Send(Message message)
+ {
+ Messages.Add(message);
+ MessageSent?.Invoke(message);
+ }
+
+ /// Sends a new error message to the log.
+ /// Main text line.
+ /// More details.
+ /// Exception thrown.
+ public static void SendError(string text, string details = "", Exception exception = null)
+ {
+ Send(new Message(Message.Types.Error, text, details, exception));
+ }
+
+ ///
+ /// Fires when a new message is sent. Use this event at Console applications or
+ /// elsewhere to track log messages at
+ /// runtime.
+ ///
+ public static event MessageHandler MessageSent;
+
+ /// Log.Message struct.
+ public struct Message
+ {
+ /// Primary constructor.
+ /// Message type.
+ /// Main text line.
+ /// More details.
+ /// Exception thrown.
+ public Message(Types type, string text, string details = "", Exception exception = null)
+ {
+ Exception = exception;
+ Details = details;
+ Text = text;
+ Type = type;
+ }
+
+ /// More details such as filename.
+ public string Details;
+
+ /// Bound exception for errors.
+ public Exception Exception;
+
+ /// Leading text.
+ public string Text;
+
+ /// Message type.
+ public Types Type;
+
+ /// Message types.
+ public enum Types
+ {
+ Info,
+ Error
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tamar.Clausewitz/Tamar.Clausewitz.csproj b/Tamar.Clausewitz/Tamar.Clausewitz.csproj
new file mode 100644
index 0000000..47b7c9a
--- /dev/null
+++ b/Tamar.Clausewitz/Tamar.Clausewitz.csproj
@@ -0,0 +1,22 @@
+
+
+ net6.0
+ Tamar.Clausewitz
+ Tamar.Clausewitz
+ Tamar.Clausewitz
+ David von Tamar
+ 2023 David von Tamar, LGPLv3
+ 0.2.0
+ https://github.com/david-tamar/clausewitz.net
+ https://github.com/david-tamar/clausewitz.net/blob/master/LICENSE
+ https://github.com/david-tamar/clausewitz.net
+ git
+ clausewitz, interpreter
+ Tamar.Clausewitz
+ David von Tamar
+ default
+
+
+ bin\Release\netcoreapp2.0\Clausewitz interpreter.xml
+
+
\ No newline at end of file