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