diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..80dd4f7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "name": "SPADE .NET Dev Container", + // SDK 10 reads SPADE.slnx (the .slnx format needs SDK 9.0.200+) and still builds the net6.0 library. + "image": "mcr.microsoft.com/dotnet/sdk:10.0", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "username": "vscode", + "installZsh": false, + "upgradePackages": false + }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false }, + "ghcr.io/reduxisu/features/rbs:1": {} + }, + "remoteUser": "vscode", + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csharp" + ] + } + }, + "postStartCommand": "if [ \"$(stat -c %U /workspaces/SPADE)\" != \"vscode\" ]; then sudo chown -R vscode:vscode /workspaces/SPADE; fi", + "postCreateCommand": "dotnet --list-sdks && dotnet --list-runtimes" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55bb012..ef9143a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 6.0.x - 8.0.x + dotnet-version: 10.0.x - name: Build run: dotnet build DiscreteParser.csproj -c Release # The test project arrives with the first fix PR; until then there is nothing to run. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa42087..fcdc2b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,9 +15,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 6.0.x - 8.0.x + dotnet-version: 10.0.x - name: Check the tag matches the project version run: | version=$(grep -oP '(?<=)[^<]+' DiscreteParser.csproj) diff --git a/.github/workflows/rbs.yml b/.github/workflows/rbs.yml new file mode 100644 index 0000000..b4b1899 --- /dev/null +++ b/.github/workflows/rbs.yml @@ -0,0 +1,23 @@ +name: rbs + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + packages: write + +concurrency: + group: rbs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + uses: ReduxISU/Redux_Build_System/.github/workflows/ci.yml@main + secrets: inherit + with: + # Report only while ci.yml is still the gate. + soft: true diff --git a/.gitignore b/.gitignore index 75ef810..c5be391 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ bin/ obj/ DF.sln +.rbs/ +report.md diff --git a/DefinitionDescription.cs b/DefinitionDescription.cs new file mode 100644 index 0000000..28e0ccc --- /dev/null +++ b/DefinitionDescription.cs @@ -0,0 +1,273 @@ +using Antlr4.Runtime; +using Antlr4.Runtime.Misc; + +using SPADE.Grammar; + +namespace SPADE; + +/// +/// What a definition string's constraints say a named part is. +/// +public enum PartKind +{ + /// No constraint says what the part is. + Any, + + /// "is set". + Set, + + /// "is multiset". + Multiset, + + /// "is list". + List, + + /// "is int". + Int, + + /// "is number": a decimal number such as 2.5, -0.75 or .5; every int is one. + Number, + + /// "is string": a single value, not a set, list or multiset. + String, + + /// "subset X", with no "is" type; holds X. + Subset, + + /// "is X" or "in X" where X is another part or a set expression; holds X. + Member, +} + +/// +/// One named part of a definition string's format, such as N or E in "{(N,E) | ...}". +/// +public class DefinitionPart +{ + /// The name as written in the format, e.g. "E". + public string Name { get; } + + /// What the part's constraints say it is. An "is set", "is multiset", "is list", "is int", "is number" or "is string" constraint wins over "subset" or "in". + public PartKind Kind { get; } + + /// + /// The object the part is a subset or member of, as written, e.g. "N unorderedcross N" for "E subset N unorderedcross N". + /// Taken from the first "subset" constraint, else the first "is X" or "in X" constraint; null when there is neither. + /// + public string? Of { get; } + + /// Child indices from the outline's root to this part, e.g. [0,1] for E in "((N,E),K)". Empty when the outline is a single name. + public IReadOnlyList Path { get; } + + /// Every top-level constraint that names this part, as written, in definition order. + public IReadOnlyList Constraints { get; } + + internal DefinitionPart(string name, PartKind kind, string? of, IReadOnlyList path, IReadOnlyList constraints) + { + Name = name; + Kind = kind; + Of = of; + Path = path; + Constraints = constraints; + } + + public override string ToString() + { + return Of == null ? $"{Name}: {Kind}" : $"{Name}: {Kind} of {Of}"; + } +} + +/// +/// The names and types a definition string declares, read without parsing an instance. +/// Only the outermost set's format and constraints describe parts; sets nested inside a constraint are part of that constraint's text. +/// +public class DefinitionDescription +{ + /// The format with whitespace removed, e.g. "((N,E),K)". + public string Outline { get; } + + /// The named parts, in the order they appear in . + public IReadOnlyList Parts { get; } + + private readonly Dictionary byName; + + private DefinitionDescription(string outline, List parts) + { + Outline = outline; + Parts = parts.AsReadOnly(); + byName = new(); + foreach (DefinitionPart part in parts) + byName.TryAdd(part.Name, part); + } + + /// The part with this name. Throws KeyNotFoundException when the outline has no such name. + public DefinitionPart this[string name] + { + get + { + if (byName.TryGetValue(name, out DefinitionPart? part)) return part; + throw new KeyNotFoundException($"The definition {Outline} has no part named {name}"); + } + } + + /// Whether the outline has a part with this name. + public bool Contains(string name) + { + return byName.ContainsKey(name); + } + + /// Looks up a part by name without throwing. + public bool TryGetPart(string name, out DefinitionPart? part) + { + return byName.TryGetValue(name, out part); + } + + /// + /// Reads a definition string such as "{(N,E) | N is set, E subset N unorderedcross N}". + /// Throws FormatException naming the definition string when it can't be read. + /// + public static DefinitionDescription Parse(string definition) + { + collParser.SetContext set = ThrowingErrorListener.Parse(definition).set(); + collParser.FormatContext? format = set.format(); + if (format == null) + throw new FormatException($"Definition string \"{definition}\" is a list of values, not a format followed by |"); + + // Walk the outline once for its text and the path to every name. + List<(string name, List path)> names = new(); + string outline = WalkOutline(format, new List(), names); + HashSet nameSet = names.Select(n => n.name).ToHashSet(); + + Dictionary> constraints = new(); + Dictionary> simple = new(); + foreach (string name in nameSet) + { + constraints[name] = new(); + simple[name] = new(); + } + + foreach (collParser.ConstraintContext constraint in set.constraint()) + { + string text = SourceText(constraint); + if (constraint.TERM() != null) + { + string subject = constraint.TERM().GetText(); + if (!nameSet.Contains(subject)) continue; + constraints[subject].Add(text); + simple[subject].Add(constraint); + continue; + } + + // and / or / not: listed under every part it constrains, but says nothing about kind. + HashSet subjects = new(); + Subjects(constraint, subjects); + foreach (string subject in subjects.Where(nameSet.Contains)) + constraints[subject].Add(text); + } + + List parts = new(); + HashSet seen = new(); + foreach ((string name, List path) in names) + { + if (!seen.Add(name)) continue; + (PartKind kind, string? of) = Classify(simple[name], nameSet); + parts.Add(new DefinitionPart(name, kind, of, path.AsReadOnly(), constraints[name].AsReadOnly())); + } + + return new DefinitionDescription(outline, parts); + } + + // The format's text without whitespace, recording the child-index path to each name. + private static string WalkOutline(collParser.FormatContext format, List path, List<(string, List)> names) + { + if (format.TERM() != null) + { + string name = format.TERM().GetText(); + names.Add((name, new List(path))); + return name; + } + + collParser.FormatContext[] children = format.format(); + List texts = new(); + for (int i = 0; i < children.Length; i++) + { + path.Add(i); + texts.Add(WalkOutline(children[i], path, names)); + path.RemoveAt(path.Count - 1); + } + string open = format.Start.Text; + string close = open == "{" ? "}" : ")"; + return open + string.Join(",", texts) + close; + } + + // The names a compound constraint constrains: the subject of each simple constraint inside it. + private static void Subjects(collParser.ConstraintContext constraint, HashSet subjects) + { + if (constraint.TERM() != null) + { + subjects.Add(constraint.TERM().GetText()); + return; + } + foreach (collParser.ConstraintContext inner in constraint.constraint()) + Subjects(inner, subjects); + } + + // An "is set/multiset/list/int/number/string" wins the kind; Of comes from the first subset, else the first membership. + private static (PartKind, string?) Classify(List constraints, HashSet names) + { + PartKind? type = null; + string? subsetOf = null; + string? memberOf = null; + + foreach (collParser.ConstraintContext constraint in constraints) + { + string definition = constraint.DEFINITION().GetText(); + collParser.ObjectContext obj = constraint.@object(); + string objText = SourceText(obj); + + if (definition == "subset") + { + subsetOf ??= objText; + continue; + } + + if (definition == "is") + { + if (obj.BUILT_IN() != null) + { + PartKind? builtIn = objText switch + { + "set" => PartKind.Set, + "multiset" => PartKind.Multiset, + "list" => PartKind.List, + "string" => PartKind.String, + _ => null, + }; + type ??= builtIn; + continue; + } + if (obj.BUILT_IN_SET() != null) + { + type ??= objText == "number" ? PartKind.Number : PartKind.Int; + continue; + } + } + + // "is X" / "in X": a member of another part or of a set expression. A bare word that names + // no part (e.g. "is text") isn't something SPADE knows, so it says nothing. + if (obj.TERM() != null && !names.Contains(objText)) continue; + memberOf ??= objText; + } + + string? of = subsetOf ?? memberOf; + if (type != null) return (type.Value, of); + if (subsetOf != null) return (PartKind.Subset, subsetOf); + if (memberOf != null) return (PartKind.Member, memberOf); + return (PartKind.Any, null); + } + + // A rule's text as written in the definition string, spacing included. + private static string SourceText(ParserRuleContext context) + { + return context.Start.InputStream.GetText(Interval.Of(context.Start.StartIndex, context.Stop.StopIndex)); + } +} diff --git a/DiscreteParser.csproj b/DiscreteParser.csproj index a83145c..32a344b 100644 --- a/DiscreteParser.csproj +++ b/DiscreteParser.csproj @@ -9,6 +9,8 @@ Russell Phillips false false + + $(DefaultItemExcludes);tests/** diff --git a/Documentation/DefinitionString.md b/Documentation/DefinitionString.md index 98a8608..27ed5d7 100644 --- a/Documentation/DefinitionString.md +++ b/Documentation/DefinitionString.md @@ -14,7 +14,10 @@ In the definition string, first there is a *format string*, which is the part to After the symbol "|" is a list of assertions or *constraints* which must be true of the instance, separated by commas that are assumed to be true for a valid problem instance. The language used for defining constraints in SPADE is modeled after the language of set theory. Each constraint says that some named element is either a basic object or related to the other objects in some way. A constraint is a terminal (one of the named collections from the format string) followed by a definition and then an object. ### Definition -- "is" checks the type of a collection. It can be followed by "set", "list", or "int", and constrains the collection to be that type. An example is "U is set" which constrains U to be a set +- "is" checks the type of a collection. It can be followed by "set", "list", "int", "number" or "string", and constrains the collection to be that type. An example is "U is set" which constrains U to be a set +- "is multiset" marks a collection written with curly braces as a multiset, so repeated elements are kept instead of dropped. For example, "{N | N is multiset, N subset int}" reads {3,3,2,2} as four numbers. It applies before the other constraints, wherever it is written. See [multisets](InstanceString.md#multisets). +- "is number" checks that a collection is a single decimal number, such as a coordinate or a weight. It accepts an optional `+` or `-` sign, then digits with an optional fractional part, or a fraction with a leading dot: `5`, `-3`, `+7`, `2.5`, `-0.75`, `.5` and `-.5` are all numbers. It rejects exponents (`1e3`, `2E-4`), `NaN` and infinities, a trailing dot (`5.`), more than one dot (`1.2.3`), a sign or dot on its own, thousands separators (`"1,000"`), whitespace inside the number, and anything too large to hold as a double. The decimal point is always `.`, whatever language the machine is set to, so `"2,5"` is not a number. Every value `int` accepts is also a `number`, and a quoted value like `"2.5"` counts the same as `2.5`. Like `int`, `number` is also a set: "S subset number" and "P subset number cross number" (points with real coordinates, as in Convex Hull) both work, as does "P subset {(x,y) | x is number, y is number}". Since `number` is a keyword, it can't be used as a name in the format string or a constraint. +- "is string" checks that a collection is a single value, not a set, list or multiset. Any value counts, a word like `kitten`, words with spaces like `New York`, or a number like `5`. For example, "{(x,y) | x is string, y is string}" accepts (kitten,sitting) and rejects (kitten,{sitting}). A value with commas or brackets in it can be written in quotes, like "hello, world"; see [quoted values](InstanceString.md#quoted-values). Since `string` is a keyword, it can't be used as a name in the format string or a constraint. - "subset" checks that the collection is a subset of the object on the right. An example is "A subset B" which constrains A to be a subset of B. ### Objects diff --git a/Documentation/InstanceString.md b/Documentation/InstanceString.md index 48127dc..80736f8 100644 --- a/Documentation/InstanceString.md +++ b/Documentation/InstanceString.md @@ -11,4 +11,45 @@ So a set may look something like {a,b,c}. A list **V**, will be represented usin ``` (e_0,...,e_k) ``` -as such, an example of a list could look something like (1,2,3). Also note that since an element of a set or list may also be a set of a list, so a set of lists can be represented as {(1,2),(2,3)}. \ No newline at end of file +as such, an example of a list could look something like (1,2,3). Also note that since an element of a set or list may also be a set of a list, so a set of lists can be represented as {(1,2),(2,3)}. + +## Whitespace +Whitespace is ignored where it can't be part of a value, so an instance can be spread over several lines: + +``` +{ + (1, 2), + (2, 3) +} +``` + +reads the same as `{(1,2),(2,3)}`. The rules are: + +- Spaces, tabs and line breaks before or after `{ } ( ) ,`, and at the start and end of the input, are skipped. +- Inside a value, whitespace next to `[ ] ; : =` is dropped, so `x1 : True` reads as `x1:True`. +- Other whitespace inside a value is kept. Plain spaces stay as typed, so `{New York, Los Angeles}` holds `New York`. A run containing a tab or line break becomes a single space, so a value wrapped onto two lines, `New` and `York`, reads as `New York`. + +## Quoted values +A value can be written in double quotes, so it can hold text that would otherwise end it: `"hello, world"` is one value, and `{"a,b", c}` is a set of two. Inside quotes, everything is kept exactly as written, including `{ } ( ) ,` and all whitespace. Two escapes exist inside quotes: `\"` for a quote and `\\` for a backslash. Any other backslash in quotes is an error, as is a missing closing quote (`expected closing " at position 5, found end of input` for `"abc`). + +The quotes aren't part of the value. `"kitten"` and `kitten` are the same value, so `"5"` passes `is int`, and a quoted value passes `is string`. + +A quote can only start a value, so `ab"c"` is an error. Outside quotes a backslash is an ordinary character. + +When a set or list is turned back into text, a value inside it is quoted only if it has to be: it contains `{ } ( ) , "`, is empty, or has whitespace that the rules above would change. Every other value is written as before, so `{"a,b",c}` prints as `{"a,b",c}` and `{"a","b"}` prints as `{a,b}`. A value on its own prints as its text, without quotes. + +## Errors +An instance string that can't be read throws `SpadeParseException`, which says where the problem is, what was expected and what was found: + +``` +expected , or } at position 5, found end of input +``` + +for `{1,2`. Its `Position` (counting from 1), `Expected` and `Found` properties hold the same details. Nothing is guessed: unbalanced braces, extra text after the instance, empty elements such as `{1,,2}`, and empty input are all errors. + +Sets and lists can be nested up to 256 levels deep. Deeper input is rejected with a `SpadeParseException` rather than risking a stack overflow. + +## Multisets +A multiset is written with curly braces, like a set: `{3,3,2,2}`. Read on its own, that is a set, and the repeated elements are dropped, leaving `{3,2}`. When the definition string marks the collection with `is multiset`, every element is kept, so `{3,3,2,2}` has four elements. + +Two multisets are equal when they hold the same elements the same number of times, in any order. A set and a multiset are never equal, even with the same elements. diff --git a/Documentation/development.md b/Documentation/development.md index f936fcb..8b23bbf 100644 --- a/Documentation/development.md +++ b/Documentation/development.md @@ -1,6 +1,15 @@ # Development Feel free to contribute! Also feel free to open any issues on [github](https://github.com/Jetison333/SPADE) with bugs or feature requests. +## Tests +The tests live in tests/SPADE.Tests and use xUnit. Run them from the repository root with + +```bash +dotnet test tests/SPADE.Tests +``` + +The test project targets .NET 10, so you need the .NET 10 SDK or newer. The library itself still targets .NET 6, and SDK 10 builds it. + ## antlr If you need to edit the grammar inside of coll.g4 file, use the following commands (you will need to install antlr4): diff --git a/Documentation/example.md b/Documentation/example.md index dd57375..0670baf 100644 --- a/Documentation/example.md +++ b/Documentation/example.md @@ -28,3 +28,64 @@ foreach (UtilCollection c in graph["E"]) ``` Above is example code for parsing a graph type problem. The StringParser class is provided with a [definition string](DefinitionString.md), and then with an [instance string](InstanceString.md). It then parses and verifies the instance string. Now, you can access items of that problem by using a key access, which returns a [UtilCollection](utilcollection.md) class, which can then be further used however you need to use it. + +## Handling bad input + +Every problem with an instance string throws `SpadeParseException`: text that can't be read, a shape that doesn't match the definition string, or a failed constraint. Catch it to report bad input, for example as a 400 response: + +```c# +try +{ + graph.parse(instanceString); +} +catch (SpadeParseException e) +{ + // e.g. "expected , or } at position 5, found end of input" + // or "Constraint E subset N cross N failed: (A,X) is not a member" + Console.WriteLine(e.Message); +} +``` + +To act on an error without reading its message, use its properties. When a constraint or type check fails, `Part` names the part of the outer format it belongs to and `Element` holds the member or value that failed, in full even when the message shortens it. When that element is missing a piece from another part, `MissingValue` and `ExpectedIn` name the piece and the part: + +```c# +StringParser graph = new("{(N,E) | N is set, E subset N unorderedcross N}"); +try +{ + graph.parse("({1,2},{{1,9}})"); +} +catch (SpadeParseException e) +{ + // "Constraint E subset N unorderedcross N failed: {1,9} is not a member" + Console.WriteLine($"{e.Part} {e.Element} {e.MissingValue} {e.ExpectedIn}"); // E {1,9} 9 N +} +``` + +A failure inside a nested set, like `w is int` in `E subset {(e,w) | ...}`, belongs to the outer part (`E`) and the member of it that failed. `MissingValue` and `ExpectedIn` are null when the set isn't built from parts, as in `S subset int`, and all four are null for text that can't be read or a shape that doesn't match the definition string. + +Any other exception type means a bug in SPADE or in the definition string, not bad input. A definition string that can't be read throws `FormatException` as soon as the `StringParser` is created, before any instance is parsed. + +## Describing a definition string + +`Definition` tells you the names and types a [definition string](DefinitionString.md) declares, without needing an instance string. This is useful for building an editor or form for a problem before any instance exists. + +```c# +StringParser problem = new("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}"); +DefinitionDescription d = problem.Definition; + +Console.WriteLine(d.Outline); // ((N,E),K) + +foreach (DefinitionPart part in d.Parts) +{ + // N: Set at [0,0] + // E: Subset of N unorderedcross N at [0,1] + // K: Int at [1] + Console.WriteLine($"{part} at [{string.Join(",", part.Path)}]"); +} + +Console.WriteLine(d["E"].Constraints[0]); // E subset N unorderedcross N +``` + +Each part has a `Name`, a `Kind` (`Set`, `Multiset`, `List`, `Int`, `Number`, `String`, `Subset`, `Member`, or `Any` when nothing says what it is), `Of` (what it is a subset or member of, as written), `Path` (where it sits in the outline), and `Constraints` (every constraint naming it, as written). Only the outer format's names are parts; names inside a nested set, like `a` in `S subset {a | a subset U}`, are not. + +Creating a `StringParser` reads its definition string, so a malformed one, such as `{N | N is set` with its closing brace missing, throws a `FormatException` naming it and the position of the first error, instead of being guessed at. `Definition` is worked out the first time you use it. `DefinitionDescription.Parse(definitionString)` reads a definition string the same way without a `StringParser`, and throws the same `FormatException`. diff --git a/Documentation/index.md b/Documentation/index.md index ef2580f..5737189 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -7,4 +7,5 @@ SPADE is a tool that parses strings representing instances of discrete math type - [Instance Strings](InstanceString.md) - [Definition String](DefinitionString.md) - [Utilcollections](utilcollection.md) + - [Using SPADE from several threads](threads.md) - [SPADE Development](development.md) \ No newline at end of file diff --git a/Documentation/threads.md b/Documentation/threads.md new file mode 100644 index 0000000..0a8ff18 --- /dev/null +++ b/Documentation/threads.md @@ -0,0 +1,35 @@ +# Using SPADE from several threads + +SPADE's objects follow the usual .NET rule: build on one thread, then share. Once an instance has been parsed and frozen, any number of threads can read it at the same time. + +## Safe to share + +- **A frozen `UtilCollection`.** Hashing, `Equals`, `Contains`, `Count`, indexing, enumerating and `ToString` are all safe from several threads at once. A frozen collection can't change, and its hash is computed once and published safely. +- **Sets as lookup tables.** Everything inside a set is frozen as soon as it goes in, so a shared `HashSet` can't be changed under another thread (as long as no thread adds to the set itself). + +## Not safe to share + +- **A `StringParser` while it parses.** `parse()` replaces the parser's results, so two threads parsing with the same `StringParser` overwrite each other. Use one `StringParser` per thread, or per parse; they are cheap to create. Reading the results of a finished parse from several threads is fine. +- **A collection that is still being built.** Don't call `Add`, or assign to an index, while another thread reads the collection. The same is true of `List` and `HashSet`. + +## Recommended pattern + +Parse once, freeze the result, then hand it to the workers: + +```c# +StringParser parser = new("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}"); +parser.parse(instance); + +UtilCollection nodes = parser["N"].Freeze(); +UtilCollection edges = parser["E"].Freeze(); + +Dictionary degree = new(); +Parallel.ForEach(nodes.ToList(), node => +{ + // Every thread reads the same frozen collections. + int d = edges.ToList().Count(edge => edge.Contains(node)); + lock (degree) degree[node.ToString()] = d; +}); +``` + +`Freeze()` freezes a collection and everything inside it, and returns the same collection. After that, any attempt to change it throws `InvalidOperationException`, so a stray `Add` in a worker fails loudly instead of corrupting shared data. `IsFrozen()` tells you whether a collection is frozen. diff --git a/Documentation/utilcollection.md b/Documentation/utilcollection.md index 89e68b1..68b66d9 100644 --- a/Documentation/utilcollection.md +++ b/Documentation/utilcollection.md @@ -35,7 +35,7 @@ Hamiltonian path is a graph problem, and the string follows the formal definitio UtilCollections provides a few Utility methods to help process and extract the instance information. Each method works for both ordered and unordered collections, unless specified otherwise. ### Indexing -If the collection is ordered, then you can index into the list using square brackets to get each element of the list, the same as you would for an array. Returns a UtilCollections object. +If the collection is ordered, then you can index into the list using square brackets to get each element of the list, the same as you would for an array. Returns a UtilCollections object. Assigning to an index (`list[0] = item`) replaces that element, unless the list has been hashed (see [Collections freeze once hashed](#collections-freeze-once-hashed)). ### Enumeration Using foreach loops to iterate over each element in the collection is also possible, for example, using a UtilCollection object named collection: @@ -54,10 +54,13 @@ Returns a new UtilCollections object that is the union of the two collections. O Returns True if the two UtilCollections objects are equal to each other and False otherwise. Comparison is done by value, so if two different instances of the UtilCollections class that have the same structure and elements are compared, equals will return True. ### Count -Returns the length of the collection. +Returns the length of the collection. For a multiset, repeated elements are counted every time they occur. + +### CountOf +Returns how many times an element occurs: 0 or 1 in a set, any number in a multiset or list. ### ToList -Returns a list representing the elements of the collection. If the collection is ordered, it returns the same list. If the collection is unordered, it is cast to a list and then returned. +Returns a list representing the elements of the collection. If the collection is ordered, it returns a copy of the list, so changing the copy doesn't change the collection. If the collection is unordered, it is cast to a list and then returned. ### ToString Returns the string representation of the UtilCollection. @@ -66,7 +69,34 @@ Returns the string representation of the UtilCollection. Checks if the collection contains another UtilCollection object. Comparison is done by value. Returns true if it contains it, false if it doesn't. ### Add -Adds a UtilCollection object to the collection. +Adds a UtilCollection object to the collection. Throws `InvalidOperationException` if the collection has been hashed (see below). + +### Collections freeze once hashed +A `HashSet` or `Dictionary` files each element under the hash code it had when it went in. If the element changed afterwards, it would sit under a stale hash: `Contains` would miss it and duplicates could slip in, with no error. So the first time a UtilCollection is hashed, it freezes, and `Add` or assigning to an index throws `InvalidOperationException`. That happens when it is added to a set (including a UtilCollection set), used as a dictionary key, or put inside a list or set that is itself hashed, since hashing a collection hashes everything inside it. Multisets freeze the same way. + +To freeze a collection on purpose, call `Freeze()`. It freezes the collection and everything inside it, and returns the collection, so `new UtilCollection(text).Freeze()` works. `IsFrozen()` says whether a collection is frozen. + +Build each collection fully, then add it to a set: + +```c# +UtilCollection edge = new UtilCollection("{}"); +edge.Add(new UtilCollection("1")); +edge.Add(new UtilCollection("2")); +edges.Add(edge); // edge is frozen from here on +``` + +A collection that has never been hashed can still be changed freely. The constructors that take a `List` or `HashSet` copy it, so changing the original afterwards doesn't change the collection. + +A frozen collection can't change, so it is safe to share between threads: hashing, comparing, `Contains` and enumerating it from several threads at once all work. Building a collection is not thread-safe, the same as `List` and `HashSet`: fill it on one thread, then share it. + +### AsMultiset +Returns the same elements as a multiset. For a set read from an instance string, this brings back the repeated elements the set dropped, so `{3,3,2,2}` gives four elements. Only sets and multisets can be read as multisets. + +### IsMultiset +Returns true if the collection is a multiset. + +### IsSubsetOf +Returns true if every element occurs in the other collection at least as many times as it does in this one. For multisets this compares counts, so `{7,7}` is in `{7,7,1}` but not in `{7,1}`. Both collections must be sets or multisets. ### parseInt Attempts to parse and return itself as an integer. diff --git a/InstanceScanner.cs b/InstanceScanner.cs new file mode 100644 index 0000000..70a8a4f --- /dev/null +++ b/InstanceScanner.cs @@ -0,0 +1,200 @@ +using System.Text; + +namespace SPADE; + +/// +/// Reads an instance string such as ({1,2},{(1,2)}) into a +/// in one pass, so the time taken grows in proportion to the input's length. +/// +/// +/// Whitespace rules (#2): +/// +/// Whitespace before or after { } ( ) , and at either end of the input is skipped. +/// Inside a value, whitespace next to [ ] ; : = is dropped, so x1 : True reads as x1:True. +/// Any other whitespace inside a value is kept. A run of plain spaces stays exactly as typed +/// (New York); a run containing a tab or line break becomes one space, since a line break +/// inside a value is a wrap (New\nYork reads as New York). +/// +/// These match the rules Redux applied before parsing, so that layer can be removed. +/// +/// Quoted values (#22): a value written in double quotes, "hello, world", is kept exactly as +/// written and may contain { } ( ) , and any whitespace. Inside quotes, \" is a quote and +/// \\ a backslash; no other escape exists. A quote can't appear inside an unquoted value. +/// +/// +internal class InstanceScanner +{ + /// + /// Deepest nesting of sets and lists accepted. Far beyond any real instance; it stops + /// deeply nested input from overflowing the stack and crashing the process (#7). + /// + public const int MaxDepth = 256; + + // Characters that end a value. + private const string Structural = "{}(),"; + + // Characters that end an unquoted value: the structural ones, and a quote, which may only + // start a value (#22). + private const string ValueEnd = "{}(),\""; + + // Characters inside a value that whitespace never belongs next to. + private const string ValueDelimiters = "[];:="; + + private readonly string text; + private int pos; + + private InstanceScanner(string text) + { + this.text = text; + } + + public static UtilCollection Parse(string text) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + return new InstanceScanner(text).ParseInstance(); + } + + private UtilCollection ParseInstance() + { + SkipWhitespace(); + if (AtEnd()) throw Error("a value, { or ("); + UtilCollection result = ParseElement(0); + SkipWhitespace(); + if (!AtEnd()) throw Error("end of input"); + return result; + } + + // Expects pos at the first character of an element, after any whitespace. + private UtilCollection ParseElement(int depth) + { + char c = text[pos]; + if (c == '{' || c == '(') return ParseCollection(depth + 1); + if (Structural.IndexOf(c) >= 0) throw Error("a value, { or ("); + if (c == '"') return ParseQuotedValue(); + return ParseValue(); + } + + private UtilCollection ParseCollection(int depth) + { + if (depth > MaxDepth) throw Error($"at most {MaxDepth} levels of nesting"); + + char open = text[pos]; + char close = open == '{' ? '}' : ')'; + pos++; + + List items = new(); + SkipWhitespace(); + if (!AtEnd() && text[pos] == close) + { + pos++; + } + else + { + while (true) + { + SkipWhitespace(); + if (AtEnd()) throw Error("a value, { or ("); + items.Add(ParseElement(depth)); + + SkipWhitespace(); + if (AtEnd() || (text[pos] != ',' && text[pos] != close)) throw Error($", or {close}"); + if (text[pos++] == close) break; + } + } + + if (open == '(') return new UtilCollection(items); + return UtilCollection.FromSetItems(items); + } + + private UtilCollection ParseValue() + { + int start = pos; + while (!AtEnd() && ValueEnd.IndexOf(text[pos]) < 0) pos++; + return UtilCollection.FromValue(NormalizeValue(text, start, pos)); + } + + // Expects pos at the opening quote. The value is the text up to the closing quote, unescaped. + private UtilCollection ParseQuotedValue() + { + pos++; + StringBuilder sb = new(); + while (true) + { + if (AtEnd()) throw Error("closing \""); + char c = text[pos++]; + if (c == '"') break; + if (c == '\\') + { + if (AtEnd() || (text[pos] != '"' && text[pos] != '\\')) throw Error("\\\" or \\\\ after \\"); + c = text[pos++]; + } + sb.Append(c); + } + return UtilCollection.FromValue(sb.ToString()); + } + + /// + /// Whether a value must be written in quotes to be read back as itself: it is empty, contains + /// { } ( ) , ", or has whitespace the unquoted rules would change. + /// + internal static bool NeedsQuotes(string value) + { + if (value.Length == 0) return true; + if (value.IndexOfAny(ValueEnd.ToCharArray()) >= 0) return true; + if (char.IsWhiteSpace(value[0])) return true; + return NormalizeValue(value, 0, value.Length) != value; + } + + /// The value in quotes, with " and \ escaped as \" and \\. + internal static string Quote(string value) + { + return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + + // Applies the whitespace rules to text[start..end). text[start] is never whitespace. + private static string NormalizeValue(string text, int start, int end) + { + StringBuilder sb = new(end - start); + int i = start; + while (i < end) + { + if (!char.IsWhiteSpace(text[i])) + { + sb.Append(text[i++]); + continue; + } + + int j = i; + bool plainSpaces = true; + while (j < end && char.IsWhiteSpace(text[j])) + { + if (text[j] != ' ') plainSpaces = false; + j++; + } + + bool trailing = j == end; + bool besideDelimiter = ValueDelimiters.IndexOf(sb[sb.Length - 1]) >= 0 + || (!trailing && ValueDelimiters.IndexOf(text[j]) >= 0); + if (!trailing && !besideDelimiter) + { + if (plainSpaces) sb.Append(text, i, j - i); + else sb.Append(' '); + } + i = j; + } + return sb.ToString(); + } + + private void SkipWhitespace() + { + while (!AtEnd() && char.IsWhiteSpace(text[pos])) pos++; + } + + private bool AtEnd() => pos >= text.Length; + + private SpadeParseException Error(string expected) + { + string found = AtEnd() ? "end of input" : text[pos].ToString(); + return new SpadeParseException(pos + 1, expected, found); + } +} diff --git a/SPADE.slnx b/SPADE.slnx new file mode 100644 index 0000000..13d1b80 --- /dev/null +++ b/SPADE.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/SpadeParseException.cs b/SpadeParseException.cs new file mode 100644 index 0000000..5e1042e --- /dev/null +++ b/SpadeParseException.cs @@ -0,0 +1,81 @@ +namespace SPADE; + +/// +/// Thrown when an instance string can't be read or doesn't fit its definition string. +/// Every bad-input case throws this type, so callers can tell bad input apart from a bug. +/// +public class SpadeParseException : FormatException +{ + /// Character position in the instance string, counting from 1, when the error has one. + public int? Position { get; } + + /// What the parser expected at , when the error has one. + public string? Expected { get; } + + /// What the parser found instead, when the error has one. + public string? Found { get; } + + /// + /// The name of the part the error belongs to, from the outermost format of the definition string, + /// e.g. "E" for a failed E subset N unorderedcross N. Set for failed constraints and type checks, + /// including those inside a nested set, which belong to the part that set constrains. + /// Null for syntax errors and for an instance whose shape doesn't match the format. + /// + public string? Part { get; internal init; } + + /// + /// The member or value of that failed, in full, e.g. "{1,9}" for an edge that + /// isn't a member, or the whole value for a failed type check. The message may shorten it; this doesn't. + /// + public string? Element { get; internal init; } + + /// + /// When failed because a piece of it is missing from another part, that piece, + /// e.g. "9" for the edge {1,9} when N is {1,2}. Null when the set isn't built from parts, such as int. + /// + public string? MissingValue { get; internal init; } + + /// The part is missing from, e.g. "N". Null when is. + public string? ExpectedIn { get; internal init; } + + /// A syntax error at a position, e.g. "expected , or } at position 14, found x". + public SpadeParseException(int position, string expected, string found) + : base($"expected {expected} at position {position}, found {found}") + { + Position = position; + Expected = expected; + Found = found; + } + + /// An error with no single position, such as a failed constraint. + public SpadeParseException(string message) + : base(message) + { + } + + // A copy with the same message and position, for an outer constraint to claim an inner error + // as its own (#19). The original stays as the inner exception. + private SpadeParseException(SpadeParseException inner) + : base(inner.Message, inner) + { + Position = inner.Position; + Expected = inner.Expected; + Found = inner.Found; + Part = inner.Part; + Element = inner.Element; + MissingValue = inner.MissingValue; + ExpectedIn = inner.ExpectedIn; + } + + /// The same error, message unchanged, belonging to another part and element. + internal SpadeParseException BelongingTo(string part, string element, string? missingValue, string? expectedIn) + { + return new SpadeParseException(this) + { + Part = part, + Element = element, + MissingValue = missingValue, + ExpectedIn = expectedIn, + }; + } +} diff --git a/StringParser.cs b/StringParser.cs index 23fb6a1..11dbe51 100644 --- a/StringParser.cs +++ b/StringParser.cs @@ -7,15 +7,33 @@ using SPADE.Grammar; namespace SPADE; + public class StringParser { readonly string format; public Dictionary map = new(); - + private bool hasParsed; + // The definition string's parse tree, read once when the parser is made. + private readonly IParseTree definitionTree; + + private DefinitionDescription? definition; + + /// + /// The names and types the definition string declares, read without parsing an instance. + /// Read on first use; throws FormatException when the definition string is a list of values, not a format followed by |. + /// + public DefinitionDescription Definition => definition ??= DefinitionDescription.Parse(format); + + /// + /// Makes a parser for instances of a definition string such as "{(N,E) | N is set, E subset N unorderedcross N}". + /// Throws FormatException naming the definition string, and the position of the first error, when it can't be read; + /// a malformed definition is rejected rather than guessed at. + /// public StringParser(string f) { + definitionTree = ThrowingErrorListener.Parse(f); format = f; hasParsed = false; } @@ -23,19 +41,12 @@ public StringParser(string f) public void parse(string instance) { hasParsed = true; - AntlrInputStream stream = new AntlrInputStream(format); - collLexer lex = new collLexer(stream); - CommonTokenStream tokens = new CommonTokenStream(lex); - collParser parser = new collParser(tokens); - - parser.BuildParseTree = true; - IParseTree parseTree = parser.start(); codeGen gen = new codeGen(); - ParseTreeWalker.Default.Walk(gen, parseTree); + ParseTreeWalker.Default.Walk(gen, definitionTree); gen.Parse(new UtilCollection(instance)); - map = gen.map; + map = gen.map; } diff --git a/ThrowingErrorListener.cs b/ThrowingErrorListener.cs new file mode 100644 index 0000000..0f46be1 --- /dev/null +++ b/ThrowingErrorListener.cs @@ -0,0 +1,53 @@ +using Antlr4.Runtime; + +using SPADE.Grammar; + +namespace SPADE; + +/// +/// Turns the first lexer or parser error into a FormatException naming the definition string, +/// instead of ANTLR's default of printing the error and guessing past it (#4). +/// +internal class ThrowingErrorListener : IAntlrErrorListener, IAntlrErrorListener +{ + private readonly string definition; + + public ThrowingErrorListener(string definition) + { + this.definition = definition; + } + + /// + /// Reads a definition string into its parse tree. Throws FormatException naming the definition string, + /// and the position of the first error, when it can't be read. Nothing is printed. + /// + public static collParser.StartContext Parse(string definition) + { + if (definition == null) throw new FormatException("Definition string is null"); + + ThrowingErrorListener listener = new(definition); + collLexer lexer = new(new AntlrInputStream(definition)); + lexer.RemoveErrorListeners(); + lexer.AddErrorListener(listener); + collParser parser = new(new CommonTokenStream(lexer)); + parser.RemoveErrorListeners(); + parser.AddErrorListener(listener); + parser.BuildParseTree = true; + return parser.start(); + } + + public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e) + { + Fail(charPositionInLine, msg); + } + + public void SyntaxError(TextWriter output, IRecognizer recognizer, IToken offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e) + { + Fail(charPositionInLine, msg); + } + + private void Fail(int charPositionInLine, string msg) + { + throw new FormatException($"Definition string \"{definition}\" can't be read: {msg} at position {charPositionInLine + 1}"); + } +} diff --git a/UtilCollection.cs b/UtilCollection.cs index f63d236..d10a128 100644 --- a/UtilCollection.cs +++ b/UtilCollection.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Runtime.Serialization; +using System.Text; namespace SPADE; @@ -14,52 +15,65 @@ public class UtilCollection private bool isOrdered; private bool isValue; + // A multiset keeps every occurrence in `list`, in input order, and how often each element + // occurs in `counts` (#1). It is unordered, like a set. + private bool isMultiset; + private Dictionary? counts; + + // For a set read from an instance string: every element as written, duplicates included, + // so the set can still be read as a multiset if its definition says so. + private List? occurrences; + + // Set the first time this collection is hashed. A HashSet keeps an element under the hash it + // had when it went in, so changing the element (or anything inside it) afterwards would leave + // it under a stale hash, and lookups would quietly miss it. Once frozen, every change throws. + // Hashing a list or set hashes its elements, so they freeze too, all the way down. + // volatile: GetHashCode writes hashCode before frozen, so a thread that sees frozen also sees + // the hash. A frozen collection is safe to read from several threads. + private volatile bool frozen; + private int hashCode; public UtilCollection() { } + /// + /// Reads an instance string such as ({1,2},{(1,2)}). Throws + /// if it isn't a well-formed value, set or list. + /// See for how whitespace is handled. + /// public UtilCollection(string instance) { - if (instance[0] == '{') - { - isOrdered = false; - isValue = false; - instance = instance.Substring(1, instance.Length - 2); - while (instance != "") - { - string item = findMatchingBrace(instance); - set.Add(new UtilCollection(item)); - instance = instance.Substring(item.Length).TrimStart(','); - } - } - else if (instance[0] == '(') - { - isOrdered = true; - isValue = false; - instance = instance.Substring(1, instance.Length - 2); - while (instance != "") - { - string item = findMatchingBrace(instance); - list.Add(new UtilCollection(item)); - instance = instance.Substring(item.Length).TrimStart(','); - } - } - else - { - isValue = true; - value = instance; - } + UtilCollection parsed = InstanceScanner.Parse(instance); + set = parsed.set; + list = parsed.list; + value = parsed.value; + isOrdered = parsed.isOrdered; + isValue = parsed.isValue; + isMultiset = parsed.isMultiset; + counts = parsed.counts; + occurrences = parsed.occurrences; + } + + internal static UtilCollection FromValue(string value) + { + return new UtilCollection { value = value, isValue = true }; + } + + // Copies items, like the constructors, so the caller's list can't change this set later. + internal static UtilCollection FromSetItems(List items) + { + return new UtilCollection(items.ToHashSet()) { occurrences = new List(items) }; } - + public UtilCollection(HashSet set_) { isOrdered = false; set = new(); foreach (string i in set_) { - set.Add(new UtilCollection(i)); + set.Add(new UtilCollection(i)); } } @@ -69,80 +83,54 @@ public UtilCollection(HashSet> set_) set = new(); foreach (HashSet i in set_) { - set.Add(new UtilCollection(i)); + set.Add(new UtilCollection(i)); } } + // Both copy, so later changes to the caller's list or set can't change this collection. public UtilCollection(List list__) { - list = list__; + list = new List(list__); isOrdered = true; } public UtilCollection(HashSet set__) { - set = set__; + set = new HashSet(set__); isOrdered = false; } - private string findMatchingBrace(string instance) + public UtilCollection this[int index] { - Dictionary braces = new Dictionary - { - { '(', ')' }, - { '{', '}' }, - }; - - if (!braces.ContainsKey(instance[0])) + get { - return instance.Split(',')[0]; + if (!isOrdered) throw new InvalidOperationException("Cannot index into a set"); + return list[index]; } - - Stack stack = new Stack(); - - for (int i = 0; i < instance.Length; i++) + set { - if (braces.ContainsKey(instance[i])) - { - stack.Push(instance[i]); - } - else if (braces.Values.Contains(instance[i])) - { - char openBracket = stack.Pop(); - if (braces[openBracket] != instance[i]) - { - throw new Exception("Braces not matched"); - } - if (stack.Count() == 0) - { - return instance.Substring(0, i + 1); - } - } - } - throw new Exception("Braces not matched"); - } - - public UtilCollection this[int index] - { - get { if (!isOrdered) throw new InvalidOperationException("Cannot index into a set"); - return list[index]; - } - set { - if (!isOrdered) throw new InvalidOperationException("Cannot index into a set"); - list[index] = value; - } + ThrowIfFrozen(); + list[index] = value; + } } public void Add(UtilCollection item) { - if(isOrdered) + ThrowIfFrozen(); + if (isMultiset) + { + list.Add(item); + counts![item] = CountOf(item) + 1; + } + else if (isOrdered) { list.Add(item); } else { set.Add(item); + occurrences?.Add(item); } } @@ -150,7 +138,7 @@ public IEnumerator GetEnumerator() { if (isValue) throw new InvalidOperationException("Cannot enumerate over a value"); - if (isOrdered) + if (isOrdered || isMultiset) return list.GetEnumerator(); else return set.GetEnumerator(); @@ -182,7 +170,7 @@ public void assertOrdered() public void assertUnordered() { - if(isOrdered) throw new InvalidOperationException("Ordered"); + if (isOrdered) throw new InvalidOperationException("Ordered"); } public bool IsUnordered() @@ -199,8 +187,56 @@ public bool IsValue() return isValue; } + public bool IsMultiset() + { + return isMultiset; + } + + /// + /// The same elements as a multiset. For a set read from an instance string, this brings back + /// the duplicates the set dropped, so {3,3,2,2} gives four elements. Only for sets and multisets. + /// + public UtilCollection AsMultiset() + { + if (isValue || isOrdered) throw new InvalidOperationException("Only a set can be read as a multiset"); + if (isMultiset) return this; + + UtilCollection multiset = new UtilCollection { isMultiset = true, counts = new() }; + foreach (UtilCollection item in occurrences ?? set.ToList()) + { + multiset.Add(item); + } + return multiset; + } + + /// How many times item occurs: 0 or 1 in a set, any number in a multiset or list. + public int CountOf(UtilCollection item) + { + if (isValue) throw new InvalidOperationException("Cannot count in a value"); + if (isMultiset) return counts!.TryGetValue(item, out int n) ? n : 0; + if (isOrdered) return list.Count(x => x.Equals(item)); + return set.Contains(item) ? 1 : 0; + } + + /// + /// True if every element occurs in other at least as often as it does here, so the multiset + /// {7,7} is in {7,7,1} but not in {7,1}. Only for sets and multisets. + /// + public bool IsSubsetOf(UtilCollection other) + { + if (isValue || isOrdered || other.isValue || other.isOrdered) + throw new InvalidOperationException("IsSubsetOf needs two sets or multisets"); + + IEnumerable distinct = isMultiset ? counts!.Keys : set; + return distinct.All(item => CountOf(item) <= other.CountOf(item)); + } + public bool Contains(UtilCollection collection) { + if (isMultiset) + { + return counts!.ContainsKey(collection); + } if (isOrdered) { return list.Contains(collection); @@ -212,7 +248,7 @@ public bool Contains(UtilCollection collection) } public int Count() { - if (isOrdered) + if (isOrdered || isMultiset) { return list.Count; } @@ -224,12 +260,23 @@ public int Count() public int parseInt() { - return int.Parse(ToString()); + return SPADE.Grammar.Enums.IntValue.Parse(ToString()); } public UtilCollection Intersect(UtilCollection other) { if (isOrdered != other.isOrdered) throw new InvalidOperationException("Can't take the intersection of a set and a list"); + if (isMultiset != other.isMultiset) throw new InvalidOperationException("Can't take the intersection of a set and a multiset"); + if (isMultiset) + { + // Each element occurs as often as in whichever side has fewer. + UtilCollection result = new UtilCollection { isMultiset = true, counts = new() }; + foreach (UtilCollection item in list) + { + if (result.CountOf(item) < other.CountOf(item)) result.Add(item); + } + return result; + } if (!isOrdered) { return new UtilCollection(set.Intersect(other.set).ToHashSet()); @@ -239,10 +286,22 @@ public UtilCollection Intersect(UtilCollection other) throw new NotImplementedException(); } } - + public UtilCollection Union(UtilCollection other) { if (isOrdered != other.isOrdered) throw new InvalidOperationException("Can't take the union of a set and a list"); + if (isMultiset != other.isMultiset) throw new InvalidOperationException("Can't take the union of a set and a multiset"); + if (isMultiset) + { + // Each element occurs as often as in whichever side has more. + UtilCollection result = new UtilCollection { isMultiset = true, counts = new() }; + foreach (UtilCollection item in list) result.Add(item); + foreach (UtilCollection item in other.list) + { + if (result.CountOf(item) < other.CountOf(item)) result.Add(item); + } + return result; + } if (!isOrdered) { return new UtilCollection(set.Union(other.set).ToHashSet()); @@ -258,9 +317,9 @@ public UtilCollection Cross(UtilCollection other) UtilCollection ret = new UtilCollection(); foreach (UtilCollection a in this) { - foreach(UtilCollection b in other) + foreach (UtilCollection b in other) { - UtilCollection add = new UtilCollection(new List{a,b}); + UtilCollection add = new UtilCollection(new List { a, b }); ret.Add(add); } } @@ -273,11 +332,21 @@ public override bool Equals(object? obj) UtilCollection other = (UtilCollection)obj; + // A value, a list, a set and a multiset are never equal to each other, even when empty. + if (isValue != other.isValue || isOrdered != other.isOrdered || isMultiset != other.isMultiset) return false; + if (isValue) { return value == other.value; } + if (isMultiset) + { + return list.Count == other.list.Count + && counts!.Count == other.counts!.Count + && counts.All(entry => other.CountOf(entry.Key) == entry.Value); + } + if (isOrdered) { return list.SequenceEqual(other.list); @@ -294,16 +363,91 @@ public bool NotEquals(object? obj) return true; } + // Freezes this collection and everything inside it, so it can be shared between threads, or + // kept, knowing nothing will change it. Hashing does the freezing, all the way down. + public UtilCollection Freeze() + { + GetHashCode(); + return this; + } + + public bool IsFrozen() + { + return frozen; + } + + private void ThrowIfFrozen() + { + if (frozen) + { + throw new InvalidOperationException( + "This collection has been hashed (it is in a set or used as a key) and can't be changed; " + + "build it fully before adding it to a set."); + } + } + + // The first call freezes the collection, so the hash can't change and is worked out only once. public override int GetHashCode() { - return ToString().GetHashCode(); + if (!frozen) + { + hashCode = ComputeHashCode(); + frozen = true; + } + return hashCode; + } + + private int ComputeHashCode() + { + if (isValue) + { + return value!.GetHashCode(); + } + + if (isOrdered) + { + HashCode hash = new HashCode(); + hash.Add(isOrdered); + foreach (UtilCollection item in list) + { + hash.Add(item); + } + return hash.ToHashCode(); + } + + if (isMultiset) + { + // Order-independent, like a set, but every occurrence counts. + int total = 0; + foreach (UtilCollection item in list) + { + total = unchecked(total + item.GetHashCode()); + } + return HashCode.Combine(isOrdered, isMultiset, list.Count, total); + } + + // Sets are equal regardless of order, so combine element hashes with + // an order-independent sum rather than hashing ToString(), which + // follows insertion order. + int sum = 0; + foreach (UtilCollection item in set) + { + sum = unchecked(sum + item.GetHashCode()); + } + return HashCode.Combine(isOrdered, set.Count, sum); } public List ToList() { + if (isMultiset) + { + // A copy, so changes to it can't get out of step with the counts. + return new List(list); + } if (isOrdered) { - return list; + // A copy, so changes to it can't reach a collection that may already be frozen. + return new List(list); } else { @@ -311,31 +455,42 @@ public List ToList() } } + /// + /// A set or list as an instance string that reads back as the same collection. A value inside it + /// is quoted when it has to be, such as {"a,b",c}. A value on its own is its text, unquoted, + /// so "hello, world" read alone gives hello, world. + /// override public string ToString() { if (isValue) { return value!; } - - string str = ""; - if (isOrdered) - str += "("; - else - str += "{"; - foreach (var item in this) + StringBuilder sb = new StringBuilder(); + AppendTo(sb); + return sb.ToString(); + } + + // Builds the text in one buffer; adding strings with += copied it once per element. + private void AppendTo(StringBuilder sb) + { + if (isValue) { - //Console.WriteLine(item.ToString()); - str += item.ToString() + ","; + // Inside a collection, a value that couldn't be read back as written, such as + // "hello, world", is quoted (#22). Every other value is written as it is. + sb.Append(InstanceScanner.NeedsQuotes(value!) ? InstanceScanner.Quote(value!) : value); + return; } - str = str.TrimEnd(','); - if (isOrdered) - str += ")"; - else - str += "}"; - - return str; + sb.Append(isOrdered ? '(' : '{'); + bool first = true; + foreach (UtilCollection item in ToList()) + { + if (!first) sb.Append(','); + item.AppendTo(sb); + first = false; + } + sb.Append(isOrdered ? ')' : '}'); } } diff --git a/grammar/BuiltInSet.cs b/grammar/BuiltInSet.cs index 948aba7..e2c90ad 100644 --- a/grammar/BuiltInSet.cs +++ b/grammar/BuiltInSet.cs @@ -1,2 +1,38 @@ +using System.Globalization; +using System.Text.RegularExpressions; + namespace SPADE.Grammar.Enums; -enum BuiltInSet {integers} \ No newline at end of file + +enum BuiltInSet { integers, numbers } + +// What "number" accepts, shared by "x is number" and "S subset number" (#23). +static class NumberValue +{ + // An optional sign, then digits with an optional fractional part, or a leading-dot fraction: + // 5, -3, +7, 2.5, -0.75, .5, -.5. No exponent, no trailing dot, no separators or whitespace. + // [0-9] rather than \d, which also matches non-ASCII digits; \z rather than $, which allows a trailing newline. + private static readonly Regex Pattern = new(@"^[+-]?([0-9]+(\.[0-9]+)?|\.[0-9]+)\z", RegexOptions.CultureInvariant); + + /// Whether a single value's text is a finite decimal number, read the same on every machine. + public static bool IsNumber(string text) + { + return Pattern.IsMatch(text) + && double.TryParse(text, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out double value) + && double.IsFinite(value); + } +} + +// What "int" accepts, shared by "x is int", "S subset int" and UtilCollection.parseInt. Read with the +// invariant culture, like "number", so a machine's culture (its minus sign, say) never changes the answer. +static class IntValue +{ + public static bool TryParse(string text, out int value) + { + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + public static int Parse(string text) + { + return int.Parse(text, NumberStyles.Integer, CultureInfo.InvariantCulture); + } +} diff --git a/grammar/codeGen.cs b/grammar/codeGen.cs index 83ea6ef..d268846 100644 --- a/grammar/codeGen.cs +++ b/grammar/codeGen.cs @@ -10,18 +10,19 @@ namespace SPADE.Grammar; -class codeGen : IcollListener { +class codeGen : IcollListener +{ public Dictionary map; public Stack formatStack; - public SetDescriptor ?setDescriptor; + public SetDescriptor? setDescriptor; public Stack setDescriptorStack; public Stack constraintDescriptorStack; public Stack> constraintDescriptorStackStack; - public codeGen() : base() + public codeGen() : base() { map = new(); formatStack = new(); @@ -35,6 +36,12 @@ public void Parse(UtilCollection utilCollection) setDescriptor!.isMember(utilCollection); } + // A rule's text as written in the definition string, spacing included, for error messages. + private static string SourceText(ParserRuleContext context) + { + return context.Start.InputStream.GetText(Interval.Of(context.Start.StartIndex, context.Stop.StopIndex)); + } + /// /// Enter a parse tree produced by . /// @@ -88,10 +95,10 @@ public void ExitFormat([NotNull] collParser.FormatContext context) List lst = new(); for (int i = 0; i < context.format().Length; i++) { - lst.Add(formatStack.Pop()); + lst.Add(formatStack.Pop()); } lst.Reverse(); // have to reverse the list, since the leftmost should be the one that was popped first - formatStack.Push(new FormatDescriptor(lst)); + formatStack.Push(new FormatDescriptor(lst, SourceText(context))); } if (context.TERM() != null) { @@ -123,11 +130,18 @@ public void ExitConstraint([NotNull] collParser.ConstraintContext context) { if (context.@object().BUILT_IN() != null) { - constraintDescriptorStack.Push(new ConstraintDescriptor(context.TERM().ToString()!, context.DEFINITION().ToString()!, context.@object().BUILT_IN().ToString()!)); + constraintDescriptorStack.Push(new ConstraintDescriptor(context.TERM().ToString()!, context.DEFINITION().ToString()!, context.@object().BUILT_IN().ToString()!, SourceText(context))); + } + else if (context.@object().BUILT_IN_SET() != null && context.DEFINITION().ToString() == "is") + { + // "N is int" and "N is number" are type checks, like "N is set". The set descriptor + // pushed for them is only needed by other definitions, such as "S subset int". + setDescriptorStack.Pop(); + constraintDescriptorStack.Push(new ConstraintDescriptor(context.TERM().ToString()!, context.DEFINITION().ToString()!, context.@object().BUILT_IN_SET().ToString()!, SourceText(context))); } else { - constraintDescriptorStack.Push(new ConstraintDescriptor(context.TERM().ToString()!, context.DEFINITION().ToString()!, setDescriptorStack.Pop())); + constraintDescriptorStack.Push(new ConstraintDescriptor(context.TERM().ToString()!, context.DEFINITION().ToString()!, setDescriptorStack.Pop(), SourceText(context))); } } /// @@ -150,7 +164,8 @@ public void ExitObject([NotNull] collParser.ObjectContext context) if (context.BUILT_IN_SET() != null) { - setDescriptorStack.Push(new SetDescriptor(map, BuiltInSet.integers)); + BuiltInSet builtInSet = context.BUILT_IN_SET().GetText() == "number" ? BuiltInSet.numbers : BuiltInSet.integers; + setDescriptorStack.Push(new SetDescriptor(map, builtInSet)); } if (context.TERM() != null) diff --git a/grammar/coll.g4 b/grammar/coll.g4 index 99a2afd..2d241bf 100644 --- a/grammar/coll.g4 +++ b/grammar/coll.g4 @@ -41,11 +41,13 @@ BIN_OP : 'union' | 'unorderedcross' ; BUILT_IN : 'set' + | 'multiset' | 'list' | 'graph' | 'weightedGraph' + | 'string' ; -BUILT_IN_SET: 'int'; +BUILT_IN_SET: 'int' | 'number'; TERM : [a-zA-Z0-9.]+ ; WS : [ \t\r\n]+ -> skip ; CM : [,] ; diff --git a/grammar/coll.interp b/grammar/coll.interp index 6e7deb4..04a6b32 100644 --- a/grammar/coll.interp +++ b/grammar/coll.interp @@ -10,7 +10,7 @@ null null null null -'int' +null null null null diff --git a/grammar/coll.tokens b/grammar/coll.tokens index 15510d8..397a1f9 100644 --- a/grammar/coll.tokens +++ b/grammar/coll.tokens @@ -18,4 +18,3 @@ CM=14 ')'=4 '|'=5 'not'=7 -'int'=11 diff --git a/grammar/collLexer.cs b/grammar/collLexer.cs index e2a7c82..583fc16 100644 --- a/grammar/collLexer.cs +++ b/grammar/collLexer.cs @@ -59,8 +59,7 @@ public collLexer(ICharStream input, TextWriter output, TextWriter errorOutput) } private static readonly string[] _LiteralNames = { - null, "'{'", "'}'", "'('", "')'", "'|'", null, "'not'", null, null, null, - "'int'" + null, "'{'", "'}'", "'('", "')'", "'|'", null, "'not'" }; private static readonly string[] _SymbolicNames = { null, null, null, null, null, null, "BIN_LOG_OP", "NOT", "DEFINITION", @@ -94,7 +93,7 @@ static collLexer() { } } private static int[] _serializedATN = { - 4,0,14,159,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7, + 4,0,14,180,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7, 6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,1,0, 1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1, 5,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,5,3,5,59,8,5,1,6,1,6,1,6,1,6,1, @@ -102,49 +101,57 @@ static collLexer() { 8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8, 1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,3,8,113,8,8,1,9,1, 9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9, - 1,9,1,9,1,9,1,9,1,9,1,9,3,9,140,8,9,1,10,1,10,1,10,1,10,1,11,4,11,147, - 8,11,11,11,12,11,148,1,12,4,12,152,8,12,11,12,12,12,153,1,12,1,12,1,13, - 1,13,0,0,14,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12, - 25,13,27,14,1,0,3,4,0,46,46,48,57,65,90,97,122,3,0,9,10,13,13,32,32,1, - 0,44,44,173,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0, - 0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21, - 1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,0,0,0,1,29,1,0,0,0,3,31,1,0,0, - 0,5,33,1,0,0,0,7,35,1,0,0,0,9,37,1,0,0,0,11,58,1,0,0,0,13,60,1,0,0,0,15, - 74,1,0,0,0,17,112,1,0,0,0,19,139,1,0,0,0,21,141,1,0,0,0,23,146,1,0,0,0, - 25,151,1,0,0,0,27,157,1,0,0,0,29,30,5,123,0,0,30,2,1,0,0,0,31,32,5,125, - 0,0,32,4,1,0,0,0,33,34,5,40,0,0,34,6,1,0,0,0,35,36,5,41,0,0,36,8,1,0,0, - 0,37,38,5,124,0,0,38,10,1,0,0,0,39,40,5,97,0,0,40,41,5,110,0,0,41,59,5, - 100,0,0,42,43,5,111,0,0,43,59,5,114,0,0,44,45,5,110,0,0,45,46,5,97,0,0, - 46,47,5,110,0,0,47,59,5,100,0,0,48,49,5,120,0,0,49,50,5,111,0,0,50,59, - 5,114,0,0,51,52,5,110,0,0,52,53,5,111,0,0,53,59,5,114,0,0,54,55,5,120, - 0,0,55,56,5,110,0,0,56,57,5,111,0,0,57,59,5,114,0,0,58,39,1,0,0,0,58,42, - 1,0,0,0,58,44,1,0,0,0,58,48,1,0,0,0,58,51,1,0,0,0,58,54,1,0,0,0,59,12, - 1,0,0,0,60,61,5,110,0,0,61,62,5,111,0,0,62,63,5,116,0,0,63,14,1,0,0,0, - 64,65,5,105,0,0,65,75,5,115,0,0,66,67,5,105,0,0,67,75,5,110,0,0,68,69, - 5,115,0,0,69,70,5,117,0,0,70,71,5,98,0,0,71,72,5,115,0,0,72,73,5,101,0, - 0,73,75,5,116,0,0,74,64,1,0,0,0,74,66,1,0,0,0,74,68,1,0,0,0,75,16,1,0, - 0,0,76,77,5,117,0,0,77,78,5,110,0,0,78,79,5,105,0,0,79,80,5,111,0,0,80, - 113,5,110,0,0,81,82,5,105,0,0,82,83,5,110,0,0,83,84,5,116,0,0,84,85,5, - 101,0,0,85,86,5,114,0,0,86,87,5,115,0,0,87,88,5,101,0,0,88,89,5,99,0,0, - 89,90,5,116,0,0,90,91,5,105,0,0,91,92,5,111,0,0,92,113,5,110,0,0,93,94, - 5,99,0,0,94,95,5,114,0,0,95,96,5,111,0,0,96,97,5,115,0,0,97,113,5,115, - 0,0,98,99,5,117,0,0,99,100,5,110,0,0,100,101,5,111,0,0,101,102,5,114,0, - 0,102,103,5,100,0,0,103,104,5,101,0,0,104,105,5,114,0,0,105,106,5,101, - 0,0,106,107,5,100,0,0,107,108,5,99,0,0,108,109,5,114,0,0,109,110,5,111, - 0,0,110,111,5,115,0,0,111,113,5,115,0,0,112,76,1,0,0,0,112,81,1,0,0,0, - 112,93,1,0,0,0,112,98,1,0,0,0,113,18,1,0,0,0,114,115,5,115,0,0,115,116, - 5,101,0,0,116,140,5,116,0,0,117,118,5,108,0,0,118,119,5,105,0,0,119,120, - 5,115,0,0,120,140,5,116,0,0,121,122,5,103,0,0,122,123,5,114,0,0,123,124, - 5,97,0,0,124,125,5,112,0,0,125,140,5,104,0,0,126,127,5,119,0,0,127,128, - 5,101,0,0,128,129,5,105,0,0,129,130,5,103,0,0,130,131,5,104,0,0,131,132, - 5,116,0,0,132,133,5,101,0,0,133,134,5,100,0,0,134,135,5,71,0,0,135,136, - 5,114,0,0,136,137,5,97,0,0,137,138,5,112,0,0,138,140,5,104,0,0,139,114, - 1,0,0,0,139,117,1,0,0,0,139,121,1,0,0,0,139,126,1,0,0,0,140,20,1,0,0,0, - 141,142,5,105,0,0,142,143,5,110,0,0,143,144,5,116,0,0,144,22,1,0,0,0,145, - 147,7,0,0,0,146,145,1,0,0,0,147,148,1,0,0,0,148,146,1,0,0,0,148,149,1, - 0,0,0,149,24,1,0,0,0,150,152,7,1,0,0,151,150,1,0,0,0,152,153,1,0,0,0,153, - 151,1,0,0,0,153,154,1,0,0,0,154,155,1,0,0,0,155,156,6,12,0,0,156,26,1, - 0,0,0,157,158,7,2,0,0,158,28,1,0,0,0,7,0,58,74,112,139,148,153,1,6,0,0 + 1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1, + 9,1,9,1,9,3,9,154,8,9,1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10,3,10, + 165,8,10,1,11,4,11,168,8,11,11,11,12,11,169,1,12,4,12,173,8,12,11,12,12, + 12,174,1,12,1,12,1,13,1,13,0,0,14,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,17, + 9,19,10,21,11,23,12,25,13,27,14,1,0,3,4,0,46,46,48,57,65,90,97,122,3,0, + 9,10,13,13,32,32,1,0,44,44,197,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7, + 1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0, + 0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,0,0,0,1,29, + 1,0,0,0,3,31,1,0,0,0,5,33,1,0,0,0,7,35,1,0,0,0,9,37,1,0,0,0,11,58,1,0, + 0,0,13,60,1,0,0,0,15,74,1,0,0,0,17,112,1,0,0,0,19,153,1,0,0,0,21,164,1, + 0,0,0,23,167,1,0,0,0,25,172,1,0,0,0,27,178,1,0,0,0,29,30,5,123,0,0,30, + 2,1,0,0,0,31,32,5,125,0,0,32,4,1,0,0,0,33,34,5,40,0,0,34,6,1,0,0,0,35, + 36,5,41,0,0,36,8,1,0,0,0,37,38,5,124,0,0,38,10,1,0,0,0,39,40,5,97,0,0, + 40,41,5,110,0,0,41,59,5,100,0,0,42,43,5,111,0,0,43,59,5,114,0,0,44,45, + 5,110,0,0,45,46,5,97,0,0,46,47,5,110,0,0,47,59,5,100,0,0,48,49,5,120,0, + 0,49,50,5,111,0,0,50,59,5,114,0,0,51,52,5,110,0,0,52,53,5,111,0,0,53,59, + 5,114,0,0,54,55,5,120,0,0,55,56,5,110,0,0,56,57,5,111,0,0,57,59,5,114, + 0,0,58,39,1,0,0,0,58,42,1,0,0,0,58,44,1,0,0,0,58,48,1,0,0,0,58,51,1,0, + 0,0,58,54,1,0,0,0,59,12,1,0,0,0,60,61,5,110,0,0,61,62,5,111,0,0,62,63, + 5,116,0,0,63,14,1,0,0,0,64,65,5,105,0,0,65,75,5,115,0,0,66,67,5,105,0, + 0,67,75,5,110,0,0,68,69,5,115,0,0,69,70,5,117,0,0,70,71,5,98,0,0,71,72, + 5,115,0,0,72,73,5,101,0,0,73,75,5,116,0,0,74,64,1,0,0,0,74,66,1,0,0,0, + 74,68,1,0,0,0,75,16,1,0,0,0,76,77,5,117,0,0,77,78,5,110,0,0,78,79,5,105, + 0,0,79,80,5,111,0,0,80,113,5,110,0,0,81,82,5,105,0,0,82,83,5,110,0,0,83, + 84,5,116,0,0,84,85,5,101,0,0,85,86,5,114,0,0,86,87,5,115,0,0,87,88,5,101, + 0,0,88,89,5,99,0,0,89,90,5,116,0,0,90,91,5,105,0,0,91,92,5,111,0,0,92, + 113,5,110,0,0,93,94,5,99,0,0,94,95,5,114,0,0,95,96,5,111,0,0,96,97,5,115, + 0,0,97,113,5,115,0,0,98,99,5,117,0,0,99,100,5,110,0,0,100,101,5,111,0, + 0,101,102,5,114,0,0,102,103,5,100,0,0,103,104,5,101,0,0,104,105,5,114, + 0,0,105,106,5,101,0,0,106,107,5,100,0,0,107,108,5,99,0,0,108,109,5,114, + 0,0,109,110,5,111,0,0,110,111,5,115,0,0,111,113,5,115,0,0,112,76,1,0,0, + 0,112,81,1,0,0,0,112,93,1,0,0,0,112,98,1,0,0,0,113,18,1,0,0,0,114,115, + 5,115,0,0,115,116,5,101,0,0,116,154,5,116,0,0,117,118,5,109,0,0,118,119, + 5,117,0,0,119,120,5,108,0,0,120,121,5,116,0,0,121,122,5,105,0,0,122,123, + 5,115,0,0,123,124,5,101,0,0,124,154,5,116,0,0,125,126,5,108,0,0,126,127, + 5,105,0,0,127,128,5,115,0,0,128,154,5,116,0,0,129,130,5,103,0,0,130,131, + 5,114,0,0,131,132,5,97,0,0,132,133,5,112,0,0,133,154,5,104,0,0,134,135, + 5,119,0,0,135,136,5,101,0,0,136,137,5,105,0,0,137,138,5,103,0,0,138,139, + 5,104,0,0,139,140,5,116,0,0,140,141,5,101,0,0,141,142,5,100,0,0,142,143, + 5,71,0,0,143,144,5,114,0,0,144,145,5,97,0,0,145,146,5,112,0,0,146,154, + 5,104,0,0,147,148,5,115,0,0,148,149,5,116,0,0,149,150,5,114,0,0,150,151, + 5,105,0,0,151,152,5,110,0,0,152,154,5,103,0,0,153,114,1,0,0,0,153,117, + 1,0,0,0,153,125,1,0,0,0,153,129,1,0,0,0,153,134,1,0,0,0,153,147,1,0,0, + 0,154,20,1,0,0,0,155,156,5,105,0,0,156,157,5,110,0,0,157,165,5,116,0,0, + 158,159,5,110,0,0,159,160,5,117,0,0,160,161,5,109,0,0,161,162,5,98,0,0, + 162,163,5,101,0,0,163,165,5,114,0,0,164,155,1,0,0,0,164,158,1,0,0,0,165, + 22,1,0,0,0,166,168,7,0,0,0,167,166,1,0,0,0,168,169,1,0,0,0,169,167,1,0, + 0,0,169,170,1,0,0,0,170,24,1,0,0,0,171,173,7,1,0,0,172,171,1,0,0,0,173, + 174,1,0,0,0,174,172,1,0,0,0,174,175,1,0,0,0,175,176,1,0,0,0,176,177,6, + 12,0,0,177,26,1,0,0,0,178,179,7,2,0,0,179,28,1,0,0,0,8,0,58,74,112,153, + 164,169,174,1,6,0,0 }; public static readonly ATN _ATN = diff --git a/grammar/collLexer.interp b/grammar/collLexer.interp index be2869f..f9d8a96 100644 --- a/grammar/collLexer.interp +++ b/grammar/collLexer.interp @@ -10,7 +10,7 @@ null null null null -'int' +null null null null @@ -56,4 +56,4 @@ mode names: DEFAULT_MODE atn: -[4, 0, 14, 159, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 59, 8, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 3, 7, 75, 8, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 3, 8, 113, 8, 8, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 3, 9, 140, 8, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 11, 4, 11, 147, 8, 11, 11, 11, 12, 11, 148, 1, 12, 4, 12, 152, 8, 12, 11, 12, 12, 12, 153, 1, 12, 1, 12, 1, 13, 1, 13, 0, 0, 14, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 14, 1, 0, 3, 4, 0, 46, 46, 48, 57, 65, 90, 97, 122, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 44, 44, 173, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 1, 29, 1, 0, 0, 0, 3, 31, 1, 0, 0, 0, 5, 33, 1, 0, 0, 0, 7, 35, 1, 0, 0, 0, 9, 37, 1, 0, 0, 0, 11, 58, 1, 0, 0, 0, 13, 60, 1, 0, 0, 0, 15, 74, 1, 0, 0, 0, 17, 112, 1, 0, 0, 0, 19, 139, 1, 0, 0, 0, 21, 141, 1, 0, 0, 0, 23, 146, 1, 0, 0, 0, 25, 151, 1, 0, 0, 0, 27, 157, 1, 0, 0, 0, 29, 30, 5, 123, 0, 0, 30, 2, 1, 0, 0, 0, 31, 32, 5, 125, 0, 0, 32, 4, 1, 0, 0, 0, 33, 34, 5, 40, 0, 0, 34, 6, 1, 0, 0, 0, 35, 36, 5, 41, 0, 0, 36, 8, 1, 0, 0, 0, 37, 38, 5, 124, 0, 0, 38, 10, 1, 0, 0, 0, 39, 40, 5, 97, 0, 0, 40, 41, 5, 110, 0, 0, 41, 59, 5, 100, 0, 0, 42, 43, 5, 111, 0, 0, 43, 59, 5, 114, 0, 0, 44, 45, 5, 110, 0, 0, 45, 46, 5, 97, 0, 0, 46, 47, 5, 110, 0, 0, 47, 59, 5, 100, 0, 0, 48, 49, 5, 120, 0, 0, 49, 50, 5, 111, 0, 0, 50, 59, 5, 114, 0, 0, 51, 52, 5, 110, 0, 0, 52, 53, 5, 111, 0, 0, 53, 59, 5, 114, 0, 0, 54, 55, 5, 120, 0, 0, 55, 56, 5, 110, 0, 0, 56, 57, 5, 111, 0, 0, 57, 59, 5, 114, 0, 0, 58, 39, 1, 0, 0, 0, 58, 42, 1, 0, 0, 0, 58, 44, 1, 0, 0, 0, 58, 48, 1, 0, 0, 0, 58, 51, 1, 0, 0, 0, 58, 54, 1, 0, 0, 0, 59, 12, 1, 0, 0, 0, 60, 61, 5, 110, 0, 0, 61, 62, 5, 111, 0, 0, 62, 63, 5, 116, 0, 0, 63, 14, 1, 0, 0, 0, 64, 65, 5, 105, 0, 0, 65, 75, 5, 115, 0, 0, 66, 67, 5, 105, 0, 0, 67, 75, 5, 110, 0, 0, 68, 69, 5, 115, 0, 0, 69, 70, 5, 117, 0, 0, 70, 71, 5, 98, 0, 0, 71, 72, 5, 115, 0, 0, 72, 73, 5, 101, 0, 0, 73, 75, 5, 116, 0, 0, 74, 64, 1, 0, 0, 0, 74, 66, 1, 0, 0, 0, 74, 68, 1, 0, 0, 0, 75, 16, 1, 0, 0, 0, 76, 77, 5, 117, 0, 0, 77, 78, 5, 110, 0, 0, 78, 79, 5, 105, 0, 0, 79, 80, 5, 111, 0, 0, 80, 113, 5, 110, 0, 0, 81, 82, 5, 105, 0, 0, 82, 83, 5, 110, 0, 0, 83, 84, 5, 116, 0, 0, 84, 85, 5, 101, 0, 0, 85, 86, 5, 114, 0, 0, 86, 87, 5, 115, 0, 0, 87, 88, 5, 101, 0, 0, 88, 89, 5, 99, 0, 0, 89, 90, 5, 116, 0, 0, 90, 91, 5, 105, 0, 0, 91, 92, 5, 111, 0, 0, 92, 113, 5, 110, 0, 0, 93, 94, 5, 99, 0, 0, 94, 95, 5, 114, 0, 0, 95, 96, 5, 111, 0, 0, 96, 97, 5, 115, 0, 0, 97, 113, 5, 115, 0, 0, 98, 99, 5, 117, 0, 0, 99, 100, 5, 110, 0, 0, 100, 101, 5, 111, 0, 0, 101, 102, 5, 114, 0, 0, 102, 103, 5, 100, 0, 0, 103, 104, 5, 101, 0, 0, 104, 105, 5, 114, 0, 0, 105, 106, 5, 101, 0, 0, 106, 107, 5, 100, 0, 0, 107, 108, 5, 99, 0, 0, 108, 109, 5, 114, 0, 0, 109, 110, 5, 111, 0, 0, 110, 111, 5, 115, 0, 0, 111, 113, 5, 115, 0, 0, 112, 76, 1, 0, 0, 0, 112, 81, 1, 0, 0, 0, 112, 93, 1, 0, 0, 0, 112, 98, 1, 0, 0, 0, 113, 18, 1, 0, 0, 0, 114, 115, 5, 115, 0, 0, 115, 116, 5, 101, 0, 0, 116, 140, 5, 116, 0, 0, 117, 118, 5, 108, 0, 0, 118, 119, 5, 105, 0, 0, 119, 120, 5, 115, 0, 0, 120, 140, 5, 116, 0, 0, 121, 122, 5, 103, 0, 0, 122, 123, 5, 114, 0, 0, 123, 124, 5, 97, 0, 0, 124, 125, 5, 112, 0, 0, 125, 140, 5, 104, 0, 0, 126, 127, 5, 119, 0, 0, 127, 128, 5, 101, 0, 0, 128, 129, 5, 105, 0, 0, 129, 130, 5, 103, 0, 0, 130, 131, 5, 104, 0, 0, 131, 132, 5, 116, 0, 0, 132, 133, 5, 101, 0, 0, 133, 134, 5, 100, 0, 0, 134, 135, 5, 71, 0, 0, 135, 136, 5, 114, 0, 0, 136, 137, 5, 97, 0, 0, 137, 138, 5, 112, 0, 0, 138, 140, 5, 104, 0, 0, 139, 114, 1, 0, 0, 0, 139, 117, 1, 0, 0, 0, 139, 121, 1, 0, 0, 0, 139, 126, 1, 0, 0, 0, 140, 20, 1, 0, 0, 0, 141, 142, 5, 105, 0, 0, 142, 143, 5, 110, 0, 0, 143, 144, 5, 116, 0, 0, 144, 22, 1, 0, 0, 0, 145, 147, 7, 0, 0, 0, 146, 145, 1, 0, 0, 0, 147, 148, 1, 0, 0, 0, 148, 146, 1, 0, 0, 0, 148, 149, 1, 0, 0, 0, 149, 24, 1, 0, 0, 0, 150, 152, 7, 1, 0, 0, 151, 150, 1, 0, 0, 0, 152, 153, 1, 0, 0, 0, 153, 151, 1, 0, 0, 0, 153, 154, 1, 0, 0, 0, 154, 155, 1, 0, 0, 0, 155, 156, 6, 12, 0, 0, 156, 26, 1, 0, 0, 0, 157, 158, 7, 2, 0, 0, 158, 28, 1, 0, 0, 0, 7, 0, 58, 74, 112, 139, 148, 153, 1, 6, 0, 0] \ No newline at end of file +[4, 0, 14, 180, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 59, 8, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 3, 7, 75, 8, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 3, 8, 113, 8, 8, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 3, 9, 154, 8, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 3, 10, 165, 8, 10, 1, 11, 4, 11, 168, 8, 11, 11, 11, 12, 11, 169, 1, 12, 4, 12, 173, 8, 12, 11, 12, 12, 12, 174, 1, 12, 1, 12, 1, 13, 1, 13, 0, 0, 14, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 14, 1, 0, 3, 4, 0, 46, 46, 48, 57, 65, 90, 97, 122, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 44, 44, 197, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 1, 29, 1, 0, 0, 0, 3, 31, 1, 0, 0, 0, 5, 33, 1, 0, 0, 0, 7, 35, 1, 0, 0, 0, 9, 37, 1, 0, 0, 0, 11, 58, 1, 0, 0, 0, 13, 60, 1, 0, 0, 0, 15, 74, 1, 0, 0, 0, 17, 112, 1, 0, 0, 0, 19, 153, 1, 0, 0, 0, 21, 164, 1, 0, 0, 0, 23, 167, 1, 0, 0, 0, 25, 172, 1, 0, 0, 0, 27, 178, 1, 0, 0, 0, 29, 30, 5, 123, 0, 0, 30, 2, 1, 0, 0, 0, 31, 32, 5, 125, 0, 0, 32, 4, 1, 0, 0, 0, 33, 34, 5, 40, 0, 0, 34, 6, 1, 0, 0, 0, 35, 36, 5, 41, 0, 0, 36, 8, 1, 0, 0, 0, 37, 38, 5, 124, 0, 0, 38, 10, 1, 0, 0, 0, 39, 40, 5, 97, 0, 0, 40, 41, 5, 110, 0, 0, 41, 59, 5, 100, 0, 0, 42, 43, 5, 111, 0, 0, 43, 59, 5, 114, 0, 0, 44, 45, 5, 110, 0, 0, 45, 46, 5, 97, 0, 0, 46, 47, 5, 110, 0, 0, 47, 59, 5, 100, 0, 0, 48, 49, 5, 120, 0, 0, 49, 50, 5, 111, 0, 0, 50, 59, 5, 114, 0, 0, 51, 52, 5, 110, 0, 0, 52, 53, 5, 111, 0, 0, 53, 59, 5, 114, 0, 0, 54, 55, 5, 120, 0, 0, 55, 56, 5, 110, 0, 0, 56, 57, 5, 111, 0, 0, 57, 59, 5, 114, 0, 0, 58, 39, 1, 0, 0, 0, 58, 42, 1, 0, 0, 0, 58, 44, 1, 0, 0, 0, 58, 48, 1, 0, 0, 0, 58, 51, 1, 0, 0, 0, 58, 54, 1, 0, 0, 0, 59, 12, 1, 0, 0, 0, 60, 61, 5, 110, 0, 0, 61, 62, 5, 111, 0, 0, 62, 63, 5, 116, 0, 0, 63, 14, 1, 0, 0, 0, 64, 65, 5, 105, 0, 0, 65, 75, 5, 115, 0, 0, 66, 67, 5, 105, 0, 0, 67, 75, 5, 110, 0, 0, 68, 69, 5, 115, 0, 0, 69, 70, 5, 117, 0, 0, 70, 71, 5, 98, 0, 0, 71, 72, 5, 115, 0, 0, 72, 73, 5, 101, 0, 0, 73, 75, 5, 116, 0, 0, 74, 64, 1, 0, 0, 0, 74, 66, 1, 0, 0, 0, 74, 68, 1, 0, 0, 0, 75, 16, 1, 0, 0, 0, 76, 77, 5, 117, 0, 0, 77, 78, 5, 110, 0, 0, 78, 79, 5, 105, 0, 0, 79, 80, 5, 111, 0, 0, 80, 113, 5, 110, 0, 0, 81, 82, 5, 105, 0, 0, 82, 83, 5, 110, 0, 0, 83, 84, 5, 116, 0, 0, 84, 85, 5, 101, 0, 0, 85, 86, 5, 114, 0, 0, 86, 87, 5, 115, 0, 0, 87, 88, 5, 101, 0, 0, 88, 89, 5, 99, 0, 0, 89, 90, 5, 116, 0, 0, 90, 91, 5, 105, 0, 0, 91, 92, 5, 111, 0, 0, 92, 113, 5, 110, 0, 0, 93, 94, 5, 99, 0, 0, 94, 95, 5, 114, 0, 0, 95, 96, 5, 111, 0, 0, 96, 97, 5, 115, 0, 0, 97, 113, 5, 115, 0, 0, 98, 99, 5, 117, 0, 0, 99, 100, 5, 110, 0, 0, 100, 101, 5, 111, 0, 0, 101, 102, 5, 114, 0, 0, 102, 103, 5, 100, 0, 0, 103, 104, 5, 101, 0, 0, 104, 105, 5, 114, 0, 0, 105, 106, 5, 101, 0, 0, 106, 107, 5, 100, 0, 0, 107, 108, 5, 99, 0, 0, 108, 109, 5, 114, 0, 0, 109, 110, 5, 111, 0, 0, 110, 111, 5, 115, 0, 0, 111, 113, 5, 115, 0, 0, 112, 76, 1, 0, 0, 0, 112, 81, 1, 0, 0, 0, 112, 93, 1, 0, 0, 0, 112, 98, 1, 0, 0, 0, 113, 18, 1, 0, 0, 0, 114, 115, 5, 115, 0, 0, 115, 116, 5, 101, 0, 0, 116, 154, 5, 116, 0, 0, 117, 118, 5, 109, 0, 0, 118, 119, 5, 117, 0, 0, 119, 120, 5, 108, 0, 0, 120, 121, 5, 116, 0, 0, 121, 122, 5, 105, 0, 0, 122, 123, 5, 115, 0, 0, 123, 124, 5, 101, 0, 0, 124, 154, 5, 116, 0, 0, 125, 126, 5, 108, 0, 0, 126, 127, 5, 105, 0, 0, 127, 128, 5, 115, 0, 0, 128, 154, 5, 116, 0, 0, 129, 130, 5, 103, 0, 0, 130, 131, 5, 114, 0, 0, 131, 132, 5, 97, 0, 0, 132, 133, 5, 112, 0, 0, 133, 154, 5, 104, 0, 0, 134, 135, 5, 119, 0, 0, 135, 136, 5, 101, 0, 0, 136, 137, 5, 105, 0, 0, 137, 138, 5, 103, 0, 0, 138, 139, 5, 104, 0, 0, 139, 140, 5, 116, 0, 0, 140, 141, 5, 101, 0, 0, 141, 142, 5, 100, 0, 0, 142, 143, 5, 71, 0, 0, 143, 144, 5, 114, 0, 0, 144, 145, 5, 97, 0, 0, 145, 146, 5, 112, 0, 0, 146, 154, 5, 104, 0, 0, 147, 148, 5, 115, 0, 0, 148, 149, 5, 116, 0, 0, 149, 150, 5, 114, 0, 0, 150, 151, 5, 105, 0, 0, 151, 152, 5, 110, 0, 0, 152, 154, 5, 103, 0, 0, 153, 114, 1, 0, 0, 0, 153, 117, 1, 0, 0, 0, 153, 125, 1, 0, 0, 0, 153, 129, 1, 0, 0, 0, 153, 134, 1, 0, 0, 0, 153, 147, 1, 0, 0, 0, 154, 20, 1, 0, 0, 0, 155, 156, 5, 105, 0, 0, 156, 157, 5, 110, 0, 0, 157, 165, 5, 116, 0, 0, 158, 159, 5, 110, 0, 0, 159, 160, 5, 117, 0, 0, 160, 161, 5, 109, 0, 0, 161, 162, 5, 98, 0, 0, 162, 163, 5, 101, 0, 0, 163, 165, 5, 114, 0, 0, 164, 155, 1, 0, 0, 0, 164, 158, 1, 0, 0, 0, 165, 22, 1, 0, 0, 0, 166, 168, 7, 0, 0, 0, 167, 166, 1, 0, 0, 0, 168, 169, 1, 0, 0, 0, 169, 167, 1, 0, 0, 0, 169, 170, 1, 0, 0, 0, 170, 24, 1, 0, 0, 0, 171, 173, 7, 1, 0, 0, 172, 171, 1, 0, 0, 0, 173, 174, 1, 0, 0, 0, 174, 172, 1, 0, 0, 0, 174, 175, 1, 0, 0, 0, 175, 176, 1, 0, 0, 0, 176, 177, 6, 12, 0, 0, 177, 26, 1, 0, 0, 0, 178, 179, 7, 2, 0, 0, 179, 28, 1, 0, 0, 0, 8, 0, 58, 74, 112, 153, 164, 169, 174, 1, 6, 0, 0] \ No newline at end of file diff --git a/grammar/collLexer.tokens b/grammar/collLexer.tokens index 15510d8..397a1f9 100644 --- a/grammar/collLexer.tokens +++ b/grammar/collLexer.tokens @@ -18,4 +18,3 @@ CM=14 ')'=4 '|'=5 'not'=7 -'int'=11 diff --git a/grammar/collParser.cs b/grammar/collParser.cs index 4cbbd98..447963f 100644 --- a/grammar/collParser.cs +++ b/grammar/collParser.cs @@ -46,8 +46,7 @@ public const int }; private static readonly string[] _LiteralNames = { - null, "'{'", "'}'", "'('", "')'", "'|'", null, "'not'", null, null, null, - "'int'" + null, "'{'", "'}'", "'('", "')'", "'|'", null, "'not'" }; private static readonly string[] _SymbolicNames = { null, null, null, null, null, null, "BIN_LOG_OP", "NOT", "DEFINITION", diff --git a/grammar/desciptors/ConstraintDescriptor.cs b/grammar/desciptors/ConstraintDescriptor.cs index 70ff95e..bd6c95a 100644 --- a/grammar/desciptors/ConstraintDescriptor.cs +++ b/grammar/desciptors/ConstraintDescriptor.cs @@ -1,4 +1,5 @@ using SPADE; +using SPADE.Grammar.Enums; namespace SPADE.Grammar.descriptor; @@ -8,19 +9,47 @@ class ConstraintDescriptor String definition; SetDescriptor? set; String? built_in; + // The constraint as written in the definition string, e.g. "E subset N unorderedcross N". + String text; - public ConstraintDescriptor(String _term, String _definition, SetDescriptor _set) + public ConstraintDescriptor(String _term, String _definition, SetDescriptor _set, String _text) { - term = _term; - definition = _definition; - set = _set; + term = _term; + definition = _definition; + set = _set; + text = _text; } - public ConstraintDescriptor(String _term, String _definition, String _built_in) + public ConstraintDescriptor(String _term, String _definition, String _built_in, String _text) { - term = _term; - definition = _definition; - built_in = _built_in; + term = _term; + definition = _definition; + built_in = _built_in; + text = _text; + } + + // Values in error messages are shortened, so a huge instance doesn't make a huge message. + public static string Describe(UtilCollection collection) + { + const int maxLength = 60; + string s = collection.ToString(); + return s.Length <= maxLength ? s : s.Substring(0, maxLength) + "..."; + } + + // "N is multiset" changes how N is read, so it runs before the other constraints (#1). + public bool DeclaresMultiset => definition == "is" && built_in == "multiset"; + + // The failure belongs to this constraint's name, so a caller can find the part without reading + // the message (#19). The element is kept in full; the message shortens it. + private SpadeParseException Failure(string reason, UtilCollection element, (string Value, string Part)? missing = null) + { + return new SpadeParseException($"Constraint {text} failed: {reason}") + { + Part = term, + Element = element.ToString(), + MissingValue = missing?.Value, + ExpectedIn = missing?.Part, + }; } @@ -33,21 +62,41 @@ public void check(Dictionary map) switch (built_in) { case "set": - if (map[term].IsOrdered()) + if (map[term].IsValue() || map[term].IsOrdered() || map[term].IsMultiset()) { - throw new Exception($"Constraint {term} {definition} {built_in} failed because {term} was not a set"); + throw Failure($"{Describe(map[term])} is not a set", map[term]); } break; case "list": if (map[term].IsUnordered()) { - throw new Exception($"Constraint {term} {definition} {built_in} failed because {term} was not a list"); + throw Failure($"{Describe(map[term])} is not a list", map[term]); + } + break; + case "multiset": + if (map[term].IsValue() || map[term].IsOrdered()) + { + throw Failure($"{Describe(map[term])} is not a multiset", map[term]); } + map[term] = map[term].AsMultiset(); break; case "int": + if (!map[term].IsValue() || !IntValue.TryParse(map[term].ToString(), out _)) + { + throw Failure($"{Describe(map[term])} is not an integer", map[term]); + } + break; + case "number": + if (!map[term].IsValue() || !NumberValue.IsNumber(map[term].ToString())) + { + throw Failure($"{Describe(map[term])} is not a number", map[term]); + } + break; + case "string": + // Any single value is a string, a number-looking one like 5 included. if (!map[term].IsValue()) { - throw new Exception($"Constraint {term} {definition} {built_in} failed because {term} was not a value"); + throw Failure($"{Describe(map[term])} is not a string", map[term]); } break; } @@ -56,13 +105,30 @@ public void check(Dictionary map) case "subset": if (set != null) { - foreach (UtilCollection item in map[term]) + if (map[term].IsValue()) + { + throw Failure($"{Describe(map[term])} is not a set", map[term]); + } + foreach (UtilCollection item in map[term]) + { + bool member; + try { - if (!set.isMember(item)) - { - throw new Exception($"Constraint {term} {definition} PLACEHOLDER failed because {item} was not a subset of PLACEHOLDER"); - } + member = set.isMember(item); + } + catch (SpadeParseException e) + { + // A constraint inside a nested set failed, such as w is int in + // {(e,w) | ...}. The message stays, but the error belongs to this part + // and item. A missing piece is dropped if its part is a nested name. + bool nested = e.ExpectedIn == null || set.declaredNames.Contains(e.ExpectedIn); + throw e.BelongingTo(term, item.ToString(), nested ? null : e.MissingValue, nested ? null : e.ExpectedIn); } + if (!member) + { + throw Failure($"{Describe(item)} is not a member", item, set.FindMissing(item)); + } + } } break; } diff --git a/grammar/desciptors/SetDescriptor.cs b/grammar/desciptors/SetDescriptor.cs index 79432a4..f72e521 100644 --- a/grammar/desciptors/SetDescriptor.cs +++ b/grammar/desciptors/SetDescriptor.cs @@ -10,12 +10,20 @@ namespace SPADE.Grammar.descriptor; class SetDescriptor { - public Func isMember; + public Func isMember; public Func genSet; public Dictionary map; + // Why an item isn't a member: the first piece of it missing from a part named in the set + // expression, with that part, or null when the set isn't built from parts (#19). + private Func missing = (a) => null; + + // Names declared by nested set definitions in this expression, such as e and w in + // {(e,w) | ...}. Errors about them belong to the part the nested set constrains. + public HashSet declaredNames = new(); + public SetDescriptor(Dictionary _map) { map = _map; @@ -23,12 +31,21 @@ public SetDescriptor(Dictionary _map) genSet = () => new UtilCollection(""); } - public SetDescriptor(Dictionary _map, Func _isMember, Func _genSet) + public SetDescriptor(Dictionary _map, Func _isMember, Func _genSet) { map = _map; isMember = _isMember; genSet = _genSet; } + + // A set built from two others, such as N cross N. + private SetDescriptor(SetDescriptor left, SetDescriptor right, Func _isMember, Func _genSet, Func _missing) + : this(left.map, _isMember, _genSet) + { + missing = _missing; + declaredNames.UnionWith(left.declaredNames); + declaredNames.UnionWith(right.declaredNames); + } public SetDescriptor(Dictionary _map, UtilCollection collection) { map = _map; @@ -41,18 +58,29 @@ public SetDescriptor(Dictionary _map, String name) map = _map; isMember = (checkedColl) => map[name].Contains(checkedColl); genSet = () => map[name]; + missing = (checkedColl) => map[name].Contains(checkedColl) ? null : (checkedColl.ToString(), name); } public SetDescriptor(Dictionary _map, BuiltInSet builtInSet) { map = _map; - isMember = (checkedColl) => int.TryParse(checkedColl.ToString(), out _); + if (builtInSet == BuiltInSet.numbers) + { + // A single value only: a set or list's text is never a number anyway, but say so. + isMember = (checkedColl) => checkedColl.IsValue() && NumberValue.IsNumber(checkedColl.ToString()); + genSet = () => throw new Exception("Can't generate infinite number set"); + return; + } + isMember = (checkedColl) => IntValue.TryParse(checkedColl.ToString(), out _); genSet = () => throw new Exception("Can't generate infinite integer set"); } public SetDescriptor(Dictionary _map, FormatDescriptor formatDescriptor, List constraintDescriptors) { map = _map; + declaredNames.UnionWith(formatDescriptor.names); + // OrderBy is stable, so the other constraints keep their order. + constraintDescriptors = constraintDescriptors.OrderBy(c => c.DeclaresMultiset ? 0 : 1).ToList(); isMember = (collection) => { formatDescriptor.parseFunc(map, collection); @@ -60,7 +88,7 @@ public SetDescriptor(Dictionary _map, FormatDescriptor f { constraintDescriptor.check(map); } - return true; + return true; }; genSet = () => throw new NotImplementedException("generating every set descriptor not implemented"); @@ -72,29 +100,56 @@ public SetDescriptor(Dictionary _map, FormatDescriptor f return new SetDescriptor(_isMember, _genSet); */ + /// + /// For an item that isn't a member: the first piece of it missing from a part named in the set + /// expression, and that part. Null when no such piece is found, e.g. for int, number or a set + /// literal, or when the item has the wrong shape (a value where cross needs a pair). + /// + public (string Value, string Part)? FindMissing(UtilCollection item) + { + try + { + return missing(item); + } + catch (SpadeParseException) + { + // A nested set that throws reports its own error, not a missing piece. + return null; + } + } + public SetDescriptor Cross(SetDescriptor other) { - Func _isMember = (a) => isMember(a[0]) && other.isMember(a[1]); + Func _isMember = (a) => + a.IsOrdered() && a.Count() == 2 && isMember(a[0]) && other.isMember(a[1]); Func _genSet = () => genSet().Cross(other.genSet()); - return new SetDescriptor(map, _isMember, _genSet); + Func _missing = (a) => + { + if (!a.IsOrdered() || a.Count() != 2) return null; + return !isMember(a[0]) ? missing(a[0]) : other.missing(a[1]); + }; + return new SetDescriptor(this, other, _isMember, _genSet, _missing); } public SetDescriptor UnorderedCross(SetDescriptor other) { - Func _isMember = (a) => + Func _isMember = (a) => { + // Each member is a set of nodes, like the edge {a,b}. A list such as (a,b) + // is an ordered pair and belongs to cross, and a plain value is not an edge. + if (a.IsValue() || a.IsOrdered()) return false; + foreach (UtilCollection item in a) { - if (!item.IsOrdered()) return false; if (!isMember(item) && !other.isMember(item)) { return false; } - } + } return true; }; - Func _genSet = () => + Func _genSet = () => { UtilCollection set = new("{}"); foreach (UtilCollection first in genSet()) @@ -109,22 +164,37 @@ public SetDescriptor UnorderedCross(SetDescriptor other) } return set; }; - return new SetDescriptor(map, _isMember, _genSet); + Func _missing = (a) => + { + if (a.IsValue() || a.IsOrdered()) return null; + foreach (UtilCollection item in a) + { + if (!isMember(item) && !other.isMember(item)) + { + return missing(item) ?? other.missing(item); + } + } + return null; + }; + return new SetDescriptor(this, other, _isMember, _genSet, _missing); } public SetDescriptor Union(SetDescriptor other) { - + Func _isMember = (a) => isMember(a) || other.isMember(a); Func _genSet = () => genSet().Union(other.genSet()); - return new SetDescriptor(map, _isMember, _genSet); + // Missing from both sides; the left one is named. + Func _missing = (a) => missing(a) ?? other.missing(a); + return new SetDescriptor(this, other, _isMember, _genSet, _missing); } public SetDescriptor Intersect(SetDescriptor other) { Func _isMember = (a) => isMember(a) && other.isMember(a); Func _genSet = () => genSet().Intersect(other.genSet()); - return new SetDescriptor(map, _isMember, _genSet); + Func _missing = (a) => !isMember(a) ? missing(a) : other.missing(a); + return new SetDescriptor(this, other, _isMember, _genSet, _missing); } } \ No newline at end of file diff --git a/grammar/desciptors/formatDescriptor.cs b/grammar/desciptors/formatDescriptor.cs index d86f1ab..ce9faeb 100644 --- a/grammar/desciptors/formatDescriptor.cs +++ b/grammar/desciptors/formatDescriptor.cs @@ -8,18 +8,27 @@ class FormatDescriptor { public Action, UtilCollection> parseFunc; + // The names this format declares, e.g. N and E for (N,E). + public List names = new(); + public FormatDescriptor(String term) { - parseFunc = (map, coll) => {map[term] = coll;}; + names.Add(term); + parseFunc = (map, coll) => { map[term] = coll; }; } - public FormatDescriptor(List lst) + public FormatDescriptor(List lst, String text) { + names = lst.SelectMany(f => f.names).ToList(); parseFunc = (map, coll) => { + if (!coll.IsOrdered() || coll.Count() != lst.Count()) + { + throw new SpadeParseException($"expected a list of {lst.Count()} elements for {text}, found {ConstraintDescriptor.Describe(coll)}"); + } for (int i = 0; i < lst.Count(); i++) { - lst[i].parseFunc(map, coll[i]); + lst[i].parseFunc(map, coll[i]); } }; } diff --git a/rbs.toml b/rbs.toml new file mode 100644 index 0000000..a259daf --- /dev/null +++ b/rbs.toml @@ -0,0 +1,9 @@ +# Redux Build System pipeline config. See github.com/ReduxISU/Redux_Build_System. +# SPADE is a NuGet library with no image, so it has no [artifact] / [integration] / [push]; +# publish.yml still does the NuGet release. +engine = "dotnet" +solution = "SPADE.slnx" + +[unit-test] +# The standard we're holding to. Floor 80%. New code needs tests to keep it above the floor. +coverage-min = 80 diff --git a/tests/SPADE.Tests/ConstraintGapTests.cs b/tests/SPADE.Tests/ConstraintGapTests.cs new file mode 100644 index 0000000..4ba653e --- /dev/null +++ b/tests/SPADE.Tests/ConstraintGapTests.cs @@ -0,0 +1,42 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Definitions coll.g4 accepts but StringParser.parse doesn't handle. Each test states the behaviour +// wanted and is skipped until it's fixed; the reason says what happens today. +public class ConstraintGapTests +{ + private static StringParser Parse(string grammar, string instance) + { + StringParser parser = new StringParser(grammar); + parser.parse(instance); + return parser; + } + + [Fact(Skip = "Bug: not, and, or and the other logical operators parse, but parse() throws NullReferenceException in codeGen.ExitConstraint")] + public void LogicalConstraintsAreChecked() + { + Assert.Equal("a", Parse("{N | not N is int}", "a")["N"].ToString()); + Assert.Throws(() => Parse("{N | not N is int}", "5")); + Assert.Equal(new UtilCollection("{a}"), Parse("{N | N is set or N is list}", "{a}")["N"]); + Assert.Throws(() => Parse("{N | N is set and N is list}", "{a}")); + } + + [Fact(Skip = "Bug: a set or list literal on the right of subset throws InvalidOperationException (Stack empty) from codeGen.ExitSet")] + public void SubsetOfALiteralIsChecked() + { + Assert.Equal(new UtilCollection("{a}"), Parse("{N | N subset {a,b}}", "{a}")["N"]); + SpadeParseException e = Assert.Throws(() => Parse("{N | N subset {a,b}}", "{c}")); + Assert.Equal("N", e.Part); + Assert.Equal("c", e.Element); + } + + [Fact(Skip = "Bug: 'x in A' is never checked, so ({1},2) is accepted (ConstraintDescriptor.check has no 'in' case; see #14)")] + public void InChecksMembership() + { + Assert.Equal("1", Parse("{(A,x) | A is set, x in A}", "({1},1)")["x"].ToString()); + SpadeParseException e = Assert.Throws(() => Parse("{(A,x) | A is set, x in A}", "({1},2)")); + Assert.Equal("x", e.Part); + } +} diff --git a/tests/SPADE.Tests/DefinitionDescriptionTests.cs b/tests/SPADE.Tests/DefinitionDescriptionTests.cs new file mode 100644 index 0000000..2df3028 --- /dev/null +++ b/tests/SPADE.Tests/DefinitionDescriptionTests.cs @@ -0,0 +1,309 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// StringParser.Definition describes a definition string's names and types without an instance (#18). +public class DefinitionDescriptionTests +{ + private static DefinitionDescription Describe(string grammar) + { + return new StringParser(grammar).Definition; + } + + [Fact] + public void NestedOutlineHasPathsAndKinds() + { + DefinitionDescription d = Describe("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}"); + + Assert.Equal("((N,E),K)", d.Outline); + Assert.Equal(new[] { "N", "E", "K" }, d.Parts.Select(p => p.Name)); + + Assert.Equal(PartKind.Set, d["N"].Kind); + Assert.Null(d["N"].Of); + Assert.Equal(new[] { 0, 0 }, d["N"].Path); + + Assert.Equal(PartKind.Subset, d["E"].Kind); + Assert.Equal("N unorderedcross N", d["E"].Of); + Assert.Equal(new[] { 0, 1 }, d["E"].Path); + Assert.Equal(new[] { "E subset N unorderedcross N" }, d["E"].Constraints); + + Assert.Equal(PartKind.Int, d["K"].Kind); + Assert.Equal(new[] { 1 }, d["K"].Path); + } + + [Fact] + public void NestedSetNamesAreNotParts() + { + DefinitionDescription d = Describe("{(N,E) | N is set, E subset {(e,w) | e is N unorderedcross N, w is int}}"); + + Assert.Equal(new[] { "N", "E" }, d.Parts.Select(p => p.Name)); + Assert.Equal(PartKind.Subset, d["E"].Kind); + Assert.Equal("{(e,w) | e is N unorderedcross N, w is int}", d["E"].Of); + Assert.False(d.Contains("e")); + Assert.False(d.Contains("w")); + } + + [Theory] + [InlineData("{(N,E) | N is set, E subset {(u,v) | u is N, v is N}}", "{(u,v) | u is N, v is N}")] + [InlineData("{(U,S) | U is set, S subset {a | a subset U}}", "{a | a subset U}")] + public void SubsetOfASetComprehension(string grammar, string of) + { + DefinitionDescription d = Describe(grammar); + + Assert.Equal(2, d.Parts.Count); + Assert.Equal(PartKind.Set, d.Parts[0].Kind); + Assert.Equal(PartKind.Subset, d.Parts[1].Kind); + Assert.Equal(of, d.Parts[1].Of); + } + + [Fact] + public void IsMultisetWinsKindAndSubsetGivesOf() + { + DefinitionDescription d = Describe("{(S,T) | S is multiset, S subset int, T is int}"); + + Assert.Equal(PartKind.Multiset, d["S"].Kind); + Assert.Equal("int", d["S"].Of); + Assert.Equal(new[] { "S is multiset", "S subset int" }, d["S"].Constraints); + } + + [Fact] + public void IsSetWithSubsetKeepsSet() + { + DefinitionDescription d = Describe("{(N,A) | N is set, A is set, A subset N}"); + + Assert.Equal(PartKind.Set, d["A"].Kind); + Assert.Equal("N", d["A"].Of); + } + + [Fact] + public void SingleNameOutlineHasEmptyPath() + { + DefinitionDescription d = Describe("{N | N is set}"); + + Assert.Equal("N", d.Outline); + Assert.Empty(d["N"].Path); + Assert.Equal(PartKind.Set, d["N"].Kind); + } + + [Fact] + public void ListKinds() + { + DefinitionDescription d = Describe("{(T,D,P) | T is list, D is list, P is list}"); + + Assert.All(d.Parts, p => Assert.Equal(PartKind.List, p.Kind)); + Assert.Equal(new[] { 2 }, d["P"].Path); + } + + [Fact] + public void OutlineDropsWhitespace() + { + DefinitionDescription d = Describe("{(i, w, v) | i subset int cross int, w is int, v is int}"); + + Assert.Equal("(i,w,v)", d.Outline); + Assert.Equal(PartKind.Subset, d["i"].Kind); + Assert.Equal("int cross int", d["i"].Of); + } + + [Fact] + public void MembersOfOtherPartsAndExpressions() + { + DefinitionDescription d = Describe("{(N,A,u,E) | N is set, A is set, u is N, E is N cross A cross N}"); + + Assert.Equal(PartKind.Member, d["u"].Kind); + Assert.Equal("N", d["u"].Of); + Assert.Equal(PartKind.Member, d["E"].Kind); + Assert.Equal("N cross A cross N", d["E"].Of); + } + + [Fact] + public void UnconstrainedAndUnknownTypesAreAny() + { + DefinitionDescription d = Describe("{(A,B,C) | B is text, C is graph}"); + + Assert.Equal(PartKind.Any, d["A"].Kind); + Assert.Empty(d["A"].Constraints); + Assert.Equal(PartKind.Any, d["B"].Kind); + Assert.Null(d["B"].Of); + Assert.Equal(new[] { "B is text" }, d["B"].Constraints); + Assert.Equal(PartKind.Any, d["C"].Kind); + } + + [Fact] + public void IsStringIsTheStringKind() + { + DefinitionDescription d = Describe("{(x,y,S) | x is string, y is string, S is set, S is string}"); + + Assert.Equal(PartKind.String, d["x"].Kind); + Assert.Null(d["x"].Of); + Assert.Equal(new[] { "x is string" }, d["x"].Constraints); + Assert.Equal(PartKind.String, d["y"].Kind); + // The first "is" type wins, as for the other types. + Assert.Equal(PartKind.Set, d["S"].Kind); + } + + [Fact] + public void CompoundConstraintsAreListedButDontSetKind() + { + DefinitionDescription d = Describe("{(A,B) | A is set or B is set, not A is int}"); + + Assert.Equal(PartKind.Any, d["A"].Kind); + Assert.Equal(new[] { "A is set or B is set", "not A is int" }, d["A"].Constraints); + Assert.Equal(new[] { "A is set or B is set" }, d["B"].Constraints); + } + + [Fact] + public void ConstraintsOnUnknownNamesAreIgnored() + { + DefinitionDescription d = Describe("{N | N is set, X is int}"); + + Assert.Single(d.Parts); + Assert.False(d.Contains("X")); + } + + [Fact] + public void BraceOutlineKeepsBraces() + { + DefinitionDescription d = Describe("{{A,B} | A is set, B is set}"); + + Assert.Equal("{A,B}", d.Outline); + Assert.Equal(new[] { 1 }, d["B"].Path); + } + + [Fact] + public void UnknownNameThrowsKeyNotFound() + { + DefinitionDescription d = Describe("{N | N is set}"); + + KeyNotFoundException e = Assert.Throws(() => d["E"]); + Assert.Contains("E", e.Message); + Assert.False(d.TryGetPart("E", out _)); + Assert.True(d.TryGetPart("N", out DefinitionPart? n)); + Assert.Equal("N", n!.Name); + } + + [Fact] + public void BadDefinitionThrowsWhenTheParserIsMade() + { + FormatException e = Assert.Throws(() => new StringParser("not a definition")); + Assert.Contains("not a definition", e.Message); + } + + // A malformed definition string is rejected, not guessed at (#4). + [Theory] + [InlineData("{N | N is set", "Definition string \"{N | N is set\" can't be read: mismatched input '' expecting {'}', CM} at position 14")] + [InlineData("{N | N is set#}", "Definition string \"{N | N is set#}\" can't be read: token recognition error at: '#' at position 14")] + [InlineData("{N | N is set}}", "Definition string \"{N | N is set}}\" can't be read: extraneous input '}' expecting at position 15")] + public void MalformedDefinitionThrowsWithPosition(string definition, string message) + { + FormatException e = Assert.Throws(() => new StringParser(definition)); + Assert.Equal(message, e.Message); + } + + [Theory] + [InlineData("{N | N is set")] + [InlineData("{N | N is set#}")] + [InlineData("{N | N is set}}")] + [InlineData("not a definition")] + public void StringParserAndDefinitionGiveTheSameError(string definition) + { + FormatException fromParser = Assert.Throws(() => new StringParser(definition)); + FormatException fromDefinition = Assert.Throws(() => DefinitionDescription.Parse(definition)); + Assert.Equal(fromDefinition.Message, fromParser.Message); + } + + [Fact] + public void MalformedDefinitionPrintsNothing() + { + TextWriter oldOut = Console.Out; + TextWriter oldError = Console.Error; + StringWriter output = new(); + try + { + Console.SetOut(output); + Console.SetError(output); + Assert.Throws(() => new StringParser("{N | N is set#}")); + Assert.Throws(() => new StringParser("{N | N is set")); + } + finally + { + Console.SetOut(oldOut); + Console.SetError(oldError); + } + Assert.Equal("", output.ToString()); + } + + [Theory] + [InlineData("{N | N is set}", "{1,2}")] + [InlineData("{(N,E) | N is set, E subset N unorderedcross N}", "({1,2},{{1,2}})")] + [InlineData("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}", "(({1,2},{{1,2}}),1)")] + [InlineData("{(N,E) | N is set, E subset {(e, w) | e is N unorderedcross N, w is int}}", "({1,2},{({1,2},3)})")] + public void ValidDefinitionsStillConstructAndParse(string definition, string instance) + { + StringParser parser = new(definition); + parser.parse(instance); + Assert.NotEmpty(parser.map); + } + + [Fact] + public void OneParserParsesSeveralInstances() + { + StringParser parser = new("{(N,E) | N is set, E subset N unorderedcross N}"); + + parser.parse("({1,2},{{1,2}})"); + Assert.Equal(new UtilCollection("{1,2}"), parser["N"]); + + parser.parse("({a,b,c},{{a,c}})"); + Assert.Equal(new UtilCollection("{a,b,c}"), parser["N"]); + Assert.Equal(new UtilCollection("{{a,c}}"), parser["E"]); + } + + [Fact] + public void PartToStringShowsKindAndWhatItIsOf() + { + DefinitionDescription d = Describe("{(N,E) | N is set, E subset N cross N}"); + + Assert.Equal("N: Set", d["N"].ToString()); + Assert.Equal("E: Subset of N cross N", d["E"].ToString()); + } + + [Fact] + public void ListOfValuesIsNotADefinition() + { + FormatException e = Assert.Throws(() => Describe("{a,b}")); + Assert.Equal("Definition string \"{a,b}\" is a list of values, not a format followed by |", e.Message); + } + + // Lexer errors (a character the grammar has no token for) and parser errors both give the position. + [Theory] + [InlineData("{N | N is set#}", "token recognition error at: '#' at position 14")] + [InlineData("{N | N is set", "mismatched input '' expecting {'}', CM} at position 14")] + [InlineData("{N | N is set}}", "extraneous input '}' expecting at position 15")] + public void SyntaxErrorsGiveThePosition(string grammar, string reason) + { + FormatException e = Assert.Throws(() => Describe(grammar)); + Assert.Equal($"Definition string \"{grammar}\" can't be read: {reason}", e.Message); + } + + [Fact] + public void ParseDirectlyMatchesTheProperty() + { + Assert.Equal("(N,E)", DefinitionDescription.Parse("{(N,E) | N is set, E subset N cross N}").Outline); + Assert.Throws(() => DefinitionDescription.Parse("{(N,E) | N is}")); + } + + [Fact] + public void WorksBeforeAndAfterParse() + { + StringParser graph = new("{(N,E) | N is set, E subset N unorderedcross N}"); + + DefinitionDescription before = graph.Definition; + graph.parse("({1,2,3},{{1,2},{2,3}})"); + DefinitionDescription after = graph.Definition; + + Assert.Same(before, after); + Assert.Equal(PartKind.Subset, after["E"].Kind); + Assert.Equal(new UtilCollection("{1,2,3}"), graph["N"]); + Assert.Equal(new UtilCollection("{{1,2},{2,3}}"), graph["E"]); + } +} diff --git a/tests/SPADE.Tests/DefinitionErrorTests.cs b/tests/SPADE.Tests/DefinitionErrorTests.cs new file mode 100644 index 0000000..05f3ab9 --- /dev/null +++ b/tests/SPADE.Tests/DefinitionErrorTests.cs @@ -0,0 +1,76 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Input that is well formed but doesn't fit the definition string throws SpadeParseException +// with a message naming what failed (#4). +public class DefinitionErrorTests +{ + private const string Graph = "{(N,E) | N is set, E subset N unorderedcross N}"; + + private static SpadeParseException Fails(string grammar, string instance) + { + return Assert.Throws(() => new StringParser(grammar).parse(instance)); + } + + [Theory] + [InlineData("({1,2},{{1,2}},extra)", "expected a list of 2 elements for (N,E), found ({1,2},{{1,2}},extra)")] + [InlineData("({1,2})", "expected a list of 2 elements for (N,E), found ({1,2})")] + [InlineData("{{1,2},{{1,2}}}", "expected a list of 2 elements for (N,E), found ")] + [InlineData("graph", "expected a list of 2 elements for (N,E), found graph")] + public void WrongShapeNamesTheFormat(string instance, string messageStart) + { + Assert.StartsWith(messageStart, Fails(Graph, instance).Message); + } + + [Fact] + public void FailedSubsetNamesConstraintAndMember() + { + Assert.Equal( + "Constraint E subset N unorderedcross N failed: {1,9} is not a member", + Fails(Graph, "({1,2},{{1,9}})").Message); + } + + [Fact] + public void SubsetOfAValueFailsCleanly() + { + Assert.Equal( + "Constraint E subset N unorderedcross N failed: edges is not a set", + Fails(Graph, "({1,2},edges)").Message); + } + + [Theory] + [InlineData("{N | N is int}", "abc", "Constraint N is int failed: abc is not an integer")] + [InlineData("{S | S is set}", "word", "Constraint S is set failed: word is not a set")] + [InlineData("{L | L is list}", "{1}", "Constraint L is list failed: {1} is not a list")] + public void FailedTypeCheckNamesConstraint(string grammar, string instance, string message) + { + Assert.Equal(message, Fails(grammar, instance).Message); + } + + [Fact] + public void LongValuesAreShortenedInMessages() + { + string big = "{" + string.Join(",", Enumerable.Range(0, 1000)) + "}"; + string message = Fails("{L | L is list}", big).Message; + Assert.True(message.Length < 150, message); + Assert.Contains("...", message); + } + + [Theory] + [InlineData("({a,b},{{a,b}})")] + [InlineData("({a,b},{(a)})")] + [InlineData("({a,b},{(a,b,a)})")] + [InlineData("({a,b},{x})")] + public void CrossRejectsNonPairsCleanly(string instance) + { + Fails("{(N,E) | N is set, E subset N cross N}", instance); + } + + [Fact] + public void SyntaxErrorsComeThroughStringParser() + { + Assert.Equal("expected a value, { or ( at position 9, found end of input", Fails(Graph, "({1,2},{").Message); + } +} diff --git a/tests/SPADE.Tests/ErrorPartTests.cs b/tests/SPADE.Tests/ErrorPartTests.cs new file mode 100644 index 0000000..46a131d --- /dev/null +++ b/tests/SPADE.Tests/ErrorPartTests.cs @@ -0,0 +1,170 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// A failed constraint names the part it belongs to, the element that failed, and the piece missing +// from another part, so a caller doesn't have to read the message (#19). +public class ErrorPartTests +{ + private const string Graph = "{(N,E) | N is set, E subset N unorderedcross N}"; + private const string Digraph = "{(N,E) | N is set, E subset N cross N}"; + // "e is N unorderedcross N" is not checked yet (see #14); these tests fail on w instead. + private const string WeightedGraph = "{(N,E) | N is set, E subset {(e,w) | e is N unorderedcross N, w is int}}"; + + private static SpadeParseException Fails(string grammar, string instance) + { + return Assert.Throws(() => new StringParser(grammar).parse(instance)); + } + + [Fact] + public void FailedSubsetNamesPartElementAndMissingValue() + { + SpadeParseException e = Fails(Graph, "({1,2},{{1,9}})"); + Assert.Equal("Constraint E subset N unorderedcross N failed: {1,9} is not a member", e.Message); + Assert.Equal("E", e.Part); + Assert.Equal("{1,9}", e.Element); + Assert.Equal("9", e.MissingValue); + Assert.Equal("N", e.ExpectedIn); + } + + [Fact] + public void CrossNamesTheMissingNode() + { + SpadeParseException e = Fails(Digraph, "({a,b},{(a,b),(a,c)})"); + Assert.Equal("E", e.Part); + Assert.Equal("(a,c)", e.Element); + Assert.Equal("c", e.MissingValue); + Assert.Equal("N", e.ExpectedIn); + } + + [Fact] + public void CrossOfTwoPartsNamesTheSideThatIsMissingTheValue() + { + SpadeParseException e = Fails("{(A,B,R) | A is set, B is set, R subset A cross B}", "({1,2},{x,y},{(1,x),(1,z)})"); + Assert.Equal("R", e.Part); + Assert.Equal("z", e.MissingValue); + Assert.Equal("B", e.ExpectedIn); + } + + [Fact] + public void CrossWithTheWrongShapeHasNoMissingValue() + { + SpadeParseException e = Fails(Digraph, "({a,b},{(a,b,a)})"); + Assert.Equal("E", e.Part); + Assert.Equal("(a,b,a)", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void SubsetOfIntHasNoMissingValue() + { + SpadeParseException e = Fails("{S | S subset int}", "{1,x}"); + Assert.Equal("Constraint S subset int failed: x is not a member", e.Message); + Assert.Equal("S", e.Part); + Assert.Equal("x", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void SubsetOfAValueNamesTheWholeValue() + { + SpadeParseException e = Fails(Graph, "({1,2},edges)"); + Assert.Equal("E", e.Part); + Assert.Equal("edges", e.Element); + Assert.Null(e.MissingValue); + } + + [Fact] + public void FailedIntCheckNamesPartAndValue() + { + SpadeParseException e = Fails("{(N,K) | N is set, K is int}", "({1,2},x)"); + Assert.Equal("Constraint K is int failed: x is not an integer", e.Message); + Assert.Equal("K", e.Part); + Assert.Equal("x", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void FailedSetCheckNamesPart() + { + SpadeParseException e = Fails(Graph, "((1,2),{{1,2}})"); + Assert.Equal("N", e.Part); + Assert.Equal("(1,2)", e.Element); + } + + [Fact] + public void NestedFailureBelongsToTheOuterPart() + { + SpadeParseException e = Fails(WeightedGraph, "({1,2},{({1,2},3),({1,2},x)})"); + Assert.Equal("Constraint w is int failed: x is not an integer", e.Message); + Assert.Equal("E", e.Part); + Assert.Equal("({1,2},x)", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void NestedShapeErrorBelongsToTheOuterPart() + { + SpadeParseException e = Fails(WeightedGraph, "({1,2},{({1,2},3,4)})"); + Assert.StartsWith("expected a list of 2 elements for (e,w), found", e.Message); + Assert.Equal("E", e.Part); + Assert.Equal("({1,2},3,4)", e.Element); + } + + [Fact] + public void NestedMissingValueKeepsATopLevelPart() + { + SpadeParseException e = Fails("{(N,E) | N is set, E subset {(e,w) | e subset N, w is int}}", "({1,2},{({1,9},3)})"); + Assert.Equal("Constraint e subset N failed: 9 is not a member", e.Message); + Assert.Equal("E", e.Part); + Assert.Equal("({1,9},3)", e.Element); + Assert.Equal("9", e.MissingValue); + Assert.Equal("N", e.ExpectedIn); + } + + [Fact] + public void NestedMissingValueDropsANestedPart() + { + SpadeParseException e = Fails("{(N,E) | N is set, E subset {(a,b) | a is set, b subset a}}", "({1},{({1},{2})})"); + Assert.Equal("E", e.Part); + Assert.Equal("({1},{2})", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void ElementIsInFullWhenTheMessageShortensIt() + { + string big = "{" + string.Join(",", Enumerable.Range(0, 1000)) + "}"; + SpadeParseException e = Fails("{L | L is list}", big); + Assert.Contains("...", e.Message); + Assert.Equal("L", e.Part); + Assert.Equal(new UtilCollection(big).ToString(), e.Element); + Assert.True(e.Element!.Length > 1000); + } + + [Fact] + public void SyntaxErrorHasNoPart() + { + SpadeParseException e = Fails(Graph, "({1,2},{"); + Assert.Equal("expected a value, { or ( at position 9, found end of input", e.Message); + Assert.Equal(9, e.Position); + Assert.Null(e.Part); + Assert.Null(e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void ShapeErrorHasNoPart() + { + SpadeParseException e = Fails(Graph, "({1,2})"); + Assert.Null(e.Part); + Assert.Null(e.Element); + } +} diff --git a/tests/SPADE.Tests/GrammarListenerTests.cs b/tests/SPADE.Tests/GrammarListenerTests.cs new file mode 100644 index 0000000..913e887 --- /dev/null +++ b/tests/SPADE.Tests/GrammarListenerTests.cs @@ -0,0 +1,115 @@ +using Antlr4.Runtime; +using Antlr4.Runtime.Tree; +using Xunit; + +namespace SPADE.Tests; + +// The generated parser and collBaseListener are public, so a caller can walk a definition string's +// parse tree. These pin down the tree coll.g4 gives for each kind of definition. +public class GrammarListenerTests +{ + // Records each rule as it is entered and each token as it is visited; every other callback + // is collBaseListener's. + private class Recorder : collBaseListener + { + private readonly collParser parser; + public readonly List Rules = new(); + public readonly List Tokens = new(); + + public Recorder(collParser parser) + { + this.parser = parser; + } + + public override void EnterEveryRule(ParserRuleContext context) + { + Rules.Add(parser.RuleNames[context.RuleIndex]); + } + + public override void VisitTerminal(ITerminalNode node) + { + Tokens.Add(node.GetText()); + } + } + + private static (Recorder Recorder, collParser.StartContext Tree, collParser Parser) Walk(string definition) + { + collLexer lexer = new(new AntlrInputStream(definition)); + collParser parser = new(new CommonTokenStream(lexer)); + parser.RemoveErrorListeners(); + collParser.StartContext tree = parser.start(); + Recorder recorder = new(parser); + ParseTreeWalker.Default.Walk(recorder, tree); + return (recorder, tree, parser); + } + + [Fact] + public void WalksEveryRuleAndTokenInOrder() + { + var (recorder, _, parser) = Walk("{(N,E) | N is set, E subset N cross N}"); + + Assert.Equal(0, parser.NumberOfSyntaxErrors); + Assert.Equal( + new[] { "start", "set", "format", "format", "format", "constraint", "object", "constraint", "object", "object", "object" }, + recorder.Rules); + Assert.Equal( + new[] { "{", "(", "N", ",", "E", ")", "|", "N", "is", "set", ",", "E", "subset", "N", "cross", "N", "}", "" }, + recorder.Tokens); + } + + [Fact] + public void BinaryOperatorsNestToTheLeft() + { + var (_, tree, _) = Walk("{(A,B,C,D) | D subset A union B intersection C}"); + + collParser.ObjectContext obj = tree.set().constraint(0).@object(); + Assert.Equal("intersection", obj.BIN_OP().GetText()); + Assert.Equal("AunionB", obj.@object(0).GetText()); + Assert.Equal("union", obj.@object(0).BIN_OP().GetText()); + Assert.Equal("C", obj.@object(1).TERM().GetText()); + } + + [Fact] + public void LogicalOperatorsAndNotFormOneConstraint() + { + var (recorder, tree, parser) = Walk("{(A,B) | A is set or not B is int}"); + + Assert.Equal(0, parser.NumberOfSyntaxErrors); + collParser.ConstraintContext constraint = Assert.Single(tree.set().constraint()); + Assert.Equal("or", constraint.BIN_LOG_OP().GetText()); + Assert.Equal("Aisset", constraint.constraint(0).GetText()); + Assert.NotNull(constraint.constraint(1).NOT()); + Assert.Equal("int", constraint.constraint(1).constraint(0).@object().BUILT_IN_SET().GetText()); + Assert.Equal(4, recorder.Rules.Count(r => r == "constraint")); + } + + [Fact] + public void ListAndSetLiteralsAreObjects() + { + var (recorder, tree, parser) = Walk("{(A,B) | A subset (a,b), B subset {c,d}}"); + + Assert.Equal(0, parser.NumberOfSyntaxErrors); + collParser.ListContext list = tree.set().constraint(0).@object().list(); + Assert.Equal(new[] { "a", "b" }, list.TERM().Select(t => t.GetText())); + Assert.Single(list.CM()); + + collParser.SetContext literal = tree.set().constraint(1).@object().set(); + Assert.Null(literal.format()); + Assert.Equal(new[] { "c", "d" }, literal.TERM().Select(t => t.GetText())); + Assert.Contains("list", recorder.Rules); + Assert.Equal(2, recorder.Rules.Count(r => r == "set")); + } + + // With the default listeners off, the parser still recovers and counts what it skipped. + [Theory] + [InlineData("{N | N is set")] + [InlineData("{N | N is set}}")] + [InlineData("{N | N is (}")] + [InlineData("{N | N subset (a,}")] + [InlineData("{(N,) | N is set}")] + public void MalformedDefinitionsAreCountedAsSyntaxErrors(string definition) + { + var (_, _, parser) = Walk(definition); + Assert.True(parser.NumberOfSyntaxErrors > 0); + } +} diff --git a/tests/SPADE.Tests/InstanceScannerTests.cs b/tests/SPADE.Tests/InstanceScannerTests.cs new file mode 100644 index 0000000..4954f24 --- /dev/null +++ b/tests/SPADE.Tests/InstanceScannerTests.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Reading instance strings: whitespace (#2), clear errors (#4), and bounded time and depth (#7). +public class InstanceScannerTests +{ + private static UtilCollection C(string s) => new UtilCollection(s); + + // --- Whitespace (#2) --- + + [Theory] + [InlineData("{2, 3}", "{2,3}")] + [InlineData("{1,2}\n", "{1,2}")] + [InlineData(" \t{ 1 ,\r\n 2 }\r\n", "{1,2}")] + [InlineData("( {1, 2} , 4 )", "({1,2},4)")] + [InlineData("{\n (a, b),\n (c, d)\n}", "{(a,b),(c,d)}")] + [InlineData(" 3 ", "3")] + public void SkipsWhitespaceAroundStructure(string input, string expected) + { + Assert.Equal(C(expected), C(input)); + Assert.Equal(expected, C(input).ToString()); + } + + [Fact] + public void SpacedSetContainsItsValues() + { + Assert.True(C("{2, 3}").Contains(C("3"))); + } + + [Theory] + [InlineData("{New York, Los Angeles}", "New York")] + [InlineData("{x1 | !x2, x3}", "x1 | !x2")] + [InlineData("{a b}", "a b")] + public void KeepsSpacesInsideValues(string input, string value) + { + Assert.True(C(input).Contains(C(value))); + Assert.Equal(value, C(input).ToList().First(v => v.ToString().Contains(' ')).ToString()); + } + + [Theory] + [InlineData("{New\nYork}", "New York")] + [InlineData("{New \r\n York}", "New York")] + [InlineData("{a\tb}", "a b")] + public void LineBreaksAndTabsInsideValuesBecomeOneSpace(string input, string value) + { + Assert.Equal("{" + value + "}", C(input).ToString()); + } + + [Theory] + [InlineData("{x1 : True}", "{x1:True}")] + [InlineData("{a = 1, b= 2}", "{a=1,b=2}")] + [InlineData("(1 ; 2 ; 3)", "(1;2;3)")] + [InlineData("{[ 1 ]}", "{[1]}")] + public void DropsWhitespaceBesideDelimitersInsideValues(string input, string expected) + { + Assert.Equal(expected, C(input).ToString()); + } + + // --- Errors (#4) --- + + [Theory] + [InlineData("", "expected a value, { or ( at position 1, found end of input")] + [InlineData(" ", "expected a value, { or ( at position 4, found end of input")] + [InlineData("{1,2", "expected , or } at position 5, found end of input")] + [InlineData("{1,2}junk", "expected end of input at position 6, found j")] + [InlineData("{1,(2))}", "expected , or } at position 7, found )")] + [InlineData("{(1,2},3)", "expected , or ) at position 6, found }")] + [InlineData("}", "expected a value, { or ( at position 1, found }")] + [InlineData("{1,,2}", "expected a value, { or ( at position 4, found ,")] + [InlineData("{1,}", "expected a value, { or ( at position 4, found }")] + [InlineData("{,1}", "expected a value, { or ( at position 2, found ,")] + [InlineData("f(x)", "expected end of input at position 2, found (")] + [InlineData("{1, 2} x", "expected end of input at position 8, found x")] + public void MalformedInputThrowsWithPosition(string input, string message) + { + SpadeParseException e = Assert.Throws(() => C(input)); + Assert.Equal(message, e.Message); + } + + [Fact] + public void ErrorExposesPositionExpectedAndFound() + { + SpadeParseException e = Assert.Throws(() => C("{1,2}junk")); + Assert.Equal(6, e.Position); + Assert.Equal("end of input", e.Expected); + Assert.Equal("j", e.Found); + } + + [Fact] + public void ParseErrorIsAFormatException() + { + Assert.IsAssignableFrom(Assert.Throws(() => C("{"))); + } + + // --- Depth and time (#7) --- + + // InstanceScanner.MaxDepth, which is internal. + private const int MaxDepth = 256; + + private static string Nested(int depth) => new string('{', depth) + new string('}', depth); + + [Fact] + public void AcceptsNestingUpToTheLimit() + { + C(Nested(MaxDepth)); + } + + [Theory] + [InlineData(MaxDepth + 1)] + [InlineData(100_000)] + public void RejectsDeeperNestingWithoutCrashing(int depth) + { + SpadeParseException e = Assert.Throws(() => C(Nested(depth))); + Assert.Equal(MaxDepth + 1, e.Position); + } + + [Fact] + public void LargeFlatSetParsesQuickly() + { + // 40,000 elements took 104 seconds before; this is 100,000. + string input = "{" + string.Join(", ", Enumerable.Range(0, 100_000)) + "}"; + Stopwatch timer = Stopwatch.StartNew(); + UtilCollection set = C(input); + string text = set.ToString(); + timer.Stop(); + + Assert.Equal(100_000, set.Count()); + Assert.Equal(input.Replace(" ", "").Length, text.Length); + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(5), $"took {timer.Elapsed}"); + } + + [Fact] + public void DeepNestingParsesQuickly() + { + // Depth 4,000 took 103 seconds before; the limit is lower, so check many limit-depth parses. + string input = Nested(MaxDepth); + Stopwatch timer = Stopwatch.StartNew(); + for (int i = 0; i < 100; i++) C(input); + timer.Stop(); + + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(5), $"took {timer.Elapsed}"); + } +} diff --git a/tests/SPADE.Tests/MultisetTests.cs b/tests/SPADE.Tests/MultisetTests.cs new file mode 100644 index 0000000..fa31c5d --- /dev/null +++ b/tests/SPADE.Tests/MultisetTests.cs @@ -0,0 +1,231 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Multisets keep duplicates when the definition string asks for them (#1). +public class MultisetTests +{ + private static UtilCollection M(string s) => new UtilCollection(s).AsMultiset(); + + // --- Reading and counting --- + + [Fact] + public void SetsStillDropDuplicatesByDefault() + { + UtilCollection set = new UtilCollection("{3,3,2,2}"); + Assert.False(set.IsMultiset()); + Assert.Equal(2, set.Count()); + } + + [Fact] + public void AsMultisetBringsBackDuplicates() + { + UtilCollection multiset = M("{3,3,2,2}"); + Assert.True(multiset.IsMultiset()); + Assert.True(multiset.IsUnordered()); + Assert.Equal(4, multiset.Count()); + Assert.Equal(2, multiset.CountOf(new UtilCollection("3"))); + Assert.Equal(0, multiset.CountOf(new UtilCollection("9"))); + Assert.True(multiset.Contains(new UtilCollection("2"))); + } + + [Fact] + public void EnumeratesEveryOccurrenceInInputOrder() + { + UtilCollection multiset = M("{3, 2, 3, 2}"); + Assert.Equal(new[] { "3", "2", "3", "2" }, multiset.ToList().Select(x => x.ToString())); + Assert.Equal("{3,2,3,2}", multiset.ToString()); + int seen = 0; + foreach (UtilCollection _ in multiset) seen++; + Assert.Equal(4, seen); + } + + [Fact] + public void KeepsDuplicateTuplesAndNestedCollections() + { + Assert.Equal(2, M("{(10,60),(10,60)}").Count()); + Assert.Equal(2, M("{{1,2},{2,1}}").Count()); + } + + [Fact] + public void AddKeepsCounts() + { + UtilCollection set = new UtilCollection("{1,1}"); + set.Add(new UtilCollection("1")); + Assert.Equal(1, set.Count()); + Assert.Equal(3, set.AsMultiset().CountOf(new UtilCollection("1"))); + + UtilCollection multiset = M("{1}"); + multiset.Add(new UtilCollection("1")); + Assert.Equal(2, multiset.CountOf(new UtilCollection("1"))); + } + + [Fact] + public void SetBuiltInCodeBecomesMultisetOfItsElements() + { + UtilCollection set = new UtilCollection(new HashSet { "a", "b" }); + Assert.Equal(2, set.AsMultiset().Count()); + } + + [Theory] + [InlineData("(1,1)")] + [InlineData("x")] + public void OnlySetsBecomeMultisets(string input) + { + Assert.Throws(() => new UtilCollection(input).AsMultiset()); + } + + // --- Equality and hashing --- + + [Theory] + [InlineData("{3,3,2}", "{2,3,3}")] + [InlineData("{(1,2),(1,2),3}", "{3,(1,2),(1,2)}")] + [InlineData("{}", "{}")] + public void EqualWhenCountsMatchInAnyOrder(string a, string b) + { + Assert.Equal(M(a), M(b)); + Assert.Equal(M(a).GetHashCode(), M(b).GetHashCode()); + } + + [Theory] + [InlineData("{3,3,2}", "{3,2,2}")] + [InlineData("{3,3,2}", "{3,2}")] + [InlineData("{3}", "{3,3}")] + public void NotEqualWhenCountsDiffer(string a, string b) + { + Assert.NotEqual(M(a), M(b)); + } + + [Fact] + public void SetAndMultisetAreNeverEqual() + { + Assert.NotEqual(new UtilCollection("{1,2}"), M("{1,2}")); + Assert.NotEqual(M("{1,2}"), new UtilCollection("{1,2}")); + } + + [Fact] + public void MultisetsWorkAsHashSetKeys() + { + HashSet seen = new() { M("{1,1,2}") }; + Assert.Contains(M("{2,1,1}"), seen); + Assert.DoesNotContain(M("{1,2,2}"), seen); + } + + // --- Sub-multisets, union, intersection --- + + [Theory] + [InlineData("{7,7}", "{7,7,1}", true)] + [InlineData("{7,7}", "{7,1}", false)] + [InlineData("{}", "{1}", true)] + [InlineData("{1,2}", "{2,1,3}", true)] + public void IsSubsetOfComparesCounts(string a, string b, bool expected) + { + Assert.Equal(expected, M(a).IsSubsetOf(M(b))); + } + + [Fact] + public void IsSubsetOfAlsoWorksForSets() + { + Assert.True(new UtilCollection("{1,2}").IsSubsetOf(new UtilCollection("{1,2,3}"))); + Assert.False(new UtilCollection("{1,4}").IsSubsetOf(new UtilCollection("{1,2,3}"))); + Assert.Throws(() => new UtilCollection("(1)").IsSubsetOf(new UtilCollection("{1}"))); + } + + [Fact] + public void UnionTakesTheLargerCount() + { + Assert.Equal(M("{1,1,1,2,3}"), M("{1,1,2}").Union(M("{1,1,1,3}"))); + } + + [Fact] + public void IntersectTakesTheSmallerCount() + { + Assert.Equal(M("{1,1}"), M("{1,1,2}").Intersect(M("{1,1,1,3}"))); + } + + [Fact] + public void CannotMixSetsAndMultisets() + { + Assert.Throws(() => M("{1}").Union(new UtilCollection("{1}"))); + Assert.Throws(() => new UtilCollection("{1}").Intersect(M("{1}"))); + } + + // --- Definition strings --- + + private static StringParser Parse(string grammar, string instance) + { + StringParser parser = new StringParser(grammar); + parser.parse(instance); + return parser; + } + + [Theory] + [InlineData("{N | N is multiset, N subset int}")] + [InlineData("{N | N subset int, N is multiset}")] + public void IsMultisetKeepsDuplicatesWhereverItIsWritten(string grammar) + { + UtilCollection n = Parse(grammar, "{3, 3, 2, 2}")["N"]; + Assert.True(n.IsMultiset()); + Assert.Equal(4, n.Count()); + } + + [Fact] + public void OtherConstraintsCheckEveryOccurrence() + { + SpadeParseException e = Assert.Throws( + () => Parse("{N | N is multiset, N subset int}", "{3,3,x}")); + Assert.Equal("Constraint N subset int failed: x is not a member", e.Message); + } + + [Theory] + [InlineData("(3,3)")] + [InlineData("3")] + public void IsMultisetNeedsBraces(string instance) + { + SpadeParseException e = Assert.Throws(() => Parse("{N | N is multiset}", instance)); + Assert.Equal($"Constraint N is multiset failed: {instance} is not a multiset", e.Message); + } + + [Fact] + public void IsSetRejectsAMultiset() + { + Assert.Throws(() => Parse("{N | N is multiset, N is set}", "{1,1}")); + } + + [Fact] + public void FieldsNotMarkedStaySets() + { + StringParser parser = Parse("{(S,T) | S is multiset, T is set}", "({1,1},{2,2})"); + Assert.Equal(2, parser["S"].Count()); + Assert.Equal(1, parser["T"].Count()); + } + + // The Redux grammars proposed in #1. + + [Fact] + public void PartitionKeepsRepeatedNumbers() + { + UtilCollection n = Parse("{N | N is multiset, N subset int}", "{1,7,12,15,33,12,11,5,6,9,21,18}")["N"]; + Assert.Equal(12, n.Count()); + Assert.Equal(150, n.ToList().Sum(x => x.parseInt())); + } + + [Fact] + public void SubsetSumCertificateIsCheckedByCount() + { + UtilCollection s = Parse("{(S,T) | S is multiset, S subset int, T is int}", "({7,7,1},14)")["S"]; + UtilCollection k = Parse("{K | K is multiset}", "{7,7}")["K"]; + Assert.True(k.IsSubsetOf(s)); + Assert.False(Parse("{K | K is multiset}", "{7,7,7}")["K"].IsSubsetOf(s)); + } + + [Fact] + public void KnapsackKeepsIdenticalItems() + { + string grammar = "{(i, w, v) | i is multiset, i subset int cross int, w is int, v is int}"; + UtilCollection items = Parse(grammar, "({(10,60),(10,60),(20,100)},50,220)")["i"]; + Assert.Equal(3, items.Count()); + Assert.Equal(2, items.CountOf(new UtilCollection("(10,60)"))); + } +} diff --git a/tests/SPADE.Tests/NumberTests.cs b/tests/SPADE.Tests/NumberTests.cs new file mode 100644 index 0000000..e7d0b75 --- /dev/null +++ b/tests/SPADE.Tests/NumberTests.cs @@ -0,0 +1,214 @@ +using System.Globalization; +using System.Text; +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// "number" is a decimal type next to "int": a sign, digits and an optional fraction, read with the +// invariant culture. Every int is a number; exponents, NaN and infinities are not (#23). +public class NumberTests +{ + private static void Parse(string grammar, string instance) + { + new StringParser(grammar).parse(instance); + } + + private static SpadeParseException Fails(string grammar, string instance) + { + return Assert.Throws(() => new StringParser(grammar).parse(instance)); + } + + // 400 digits: a valid shape, but too big for a double. + private static readonly string Huge = new('9', 400); + + [Theory] + [InlineData("5")] + [InlineData("-3")] + [InlineData("+7")] + [InlineData("2.5")] + [InlineData("-0.75")] + [InlineData(".5")] + [InlineData("-.5")] + [InlineData("+.5")] + [InlineData("0")] + [InlineData("0.0")] + [InlineData("007.50")] + [InlineData("\"2.5\"")] + [InlineData(" 2.5 ")] + public void IsNumberAcceptsDecimals(string instance) + { + Parse("{B | B is number}", instance); + } + + [Theory] + [InlineData("1e3", "1e3")] + [InlineData("2E-4", "2E-4")] + [InlineData("NaN", "NaN")] + [InlineData("Infinity", "Infinity")] + [InlineData("-Infinity", "-Infinity")] + [InlineData("∞", "∞")] + [InlineData("1.2.3", "1.2.3")] + [InlineData("5.", "5.")] + [InlineData(".", ".")] + [InlineData("-", "-")] + [InlineData("+", "+")] + [InlineData("--5", "--5")] + [InlineData("x", "x")] + [InlineData("0x1A", "0x1A")] + [InlineData("1 000", "1 000")] + [InlineData("\"1,000\"", "1,000")] + [InlineData("\"2,5\"", "2,5")] + [InlineData("\" 2.5\"", " 2.5")] + [InlineData("\"2.5 \"", "2.5 ")] + [InlineData("\"\"", "")] + [InlineData("{1,2}", "{1,2}")] + [InlineData("(1,2)", "(1,2)")] + [InlineData("{}", "{}")] + public void IsNumberRejectsNonNumbers(string instance, string described) + { + SpadeParseException e = Fails("{B | B is number}", instance); + Assert.Equal($"Constraint B is number failed: {described} is not a number", e.Message); + } + + [Fact] + public void IsNumberRejectsAValueTooBigForADouble() + { + SpadeParseException e = Fails("{B | B is number}", Huge); + Assert.EndsWith(" is not a number", e.Message); + Assert.Equal("B", e.Part); + Assert.Equal(Huge, e.Element); + } + + [Fact] + public void FailedNumberCheckNamesPartAndValue() + { + SpadeParseException e = Fails("{(N,K) | N is set, K is number}", "({1,2},x)"); + Assert.Equal("Constraint K is number failed: x is not a number", e.Message); + Assert.Equal("K", e.Part); + Assert.Equal("x", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void QuotedNumberIsANumber() + { + StringParser parser = new("{(K,S) | K is number, S subset number}"); + parser.parse("(\"2.5\", {\"-0.75\", 3})"); + Assert.Equal("2.5", parser["K"].ToString()); + Assert.Equal(2, parser["S"].Count()); + } + + [Theory] + [InlineData("3")] + [InlineData("-12")] + [InlineData("+4")] + [InlineData("0")] + [InlineData("\"5\"")] + [InlineData("2147483647")] + [InlineData("-2147483648")] + public void EveryIntIsANumber(string instance) + { + Parse("{N | N is int}", instance); + Parse("{N | N is number}", instance); + Parse("{S | S subset number}", "{" + instance + "}"); + } + + [Fact] + public void IntStillRejectsDecimals() + { + SpadeParseException e = Fails("{N | N is int}", "2.5"); + Assert.Equal("Constraint N is int failed: 2.5 is not an integer", e.Message); + } + + [Fact] + public void SubsetNumberAcceptsDecimals() + { + StringParser parser = new("{S | S subset number}"); + parser.parse("{1, 2.5, -.5, +7}"); + Assert.Equal(4, parser["S"].Count()); + } + + [Theory] + [InlineData("{1, 2.5, x}", "x")] + [InlineData("{1, 1e3}", "1e3")] + [InlineData("{1, {2}}", "{2}")] + public void SubsetNumberRejectsANonNumber(string instance, string bad) + { + SpadeParseException e = Fails("{S | S subset number}", instance); + Assert.Equal($"Constraint S subset number failed: {bad} is not a member", e.Message); + Assert.Equal("S", e.Part); + Assert.Equal(bad, e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + // Convex Hull: a set of points with real coordinates, written either way. + [Theory] + [InlineData("{P | P subset number cross number}")] + [InlineData("{P | P subset {(x,y) | x is number, y is number}}")] + public void ConvexHullPointsAreNumberPairs(string grammar) + { + StringParser parser = new(grammar); + parser.parse("{(0,0), (2.5,-1), (-.5,3.25), (+4,\"1.5\")}"); + Assert.Equal(4, parser["P"].Count()); + } + + [Fact] + public void NumberCrossNumberRejectsANonNumberCoordinate() + { + SpadeParseException e = Fails("{P | P subset number cross number}", "{(0,0), (2.5,north)}"); + Assert.Equal("Constraint P subset number cross number failed: (2.5,north) is not a member", e.Message); + Assert.Equal("P", e.Part); + Assert.Equal("(2.5,north)", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } + + [Fact] + public void NestedNumberCheckBelongsToTheOuterPart() + { + SpadeParseException e = Fails("{P | P subset {(x,y) | x is number, y is number}}", "{(0,0), (2.5,1e3)}"); + Assert.Equal("P", e.Part); + Assert.Contains("1e3 is not a number", e.Message); + } + + [Fact] + public void NumberCrossNumberRejectsAValue() + { + SpadeParseException e = Fails("{P | P subset number cross number}", "{(0,0), 5}"); + Assert.Equal("Constraint P subset number cross number failed: 5 is not a member", e.Message); + Assert.Null(e.MissingValue); + } + + [Fact] + public void NumbersDoNotDependOnTheMachineCulture() + { + CultureInfo saved = CultureInfo.CurrentCulture; + try + { + // German writes 2,5 for two and a half. + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Parse("{B | B is number}", "2.5"); + Parse("{S | S subset number}", "{2.5, -.75}"); + SpadeParseException e = Fails("{B | B is number}", "\"2,5\""); + Assert.Equal("Constraint B is number failed: 2,5 is not a number", e.Message); + Fails("{S | S subset number}", "{\"2,5\"}"); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + [Fact] + public void DefinitionReportsNumber() + { + DefinitionDescription d = new StringParser("{((A,B),C) | A is int, B is number, C subset number cross number}").Definition; + Assert.Equal(PartKind.Int, d["A"].Kind); + Assert.Equal(PartKind.Number, d["B"].Kind); + Assert.Equal(PartKind.Subset, d["C"].Kind); + Assert.Equal("number cross number", d["C"].Of); + } +} diff --git a/tests/SPADE.Tests/QuotedValueTests.cs b/tests/SPADE.Tests/QuotedValueTests.cs new file mode 100644 index 0000000..0657c57 --- /dev/null +++ b/tests/SPADE.Tests/QuotedValueTests.cs @@ -0,0 +1,125 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// A value in double quotes may hold , { } ( ) and any whitespace, exactly as written (#22). +public class QuotedValueTests +{ + [Theory] + [InlineData("\"hello, world\"", "hello, world")] + [InlineData("\"{a} (b)\"", "{a} (b)")] + [InlineData("\" two spaces \t tab \"", " two spaces \t tab ")] + [InlineData("\"x : y\"", "x : y")] + [InlineData("\"\"", "")] + [InlineData(" \"padded\" ", "padded")] + [InlineData("\"say \\\"hi\\\"\"", "say \"hi\"")] + [InlineData("\"C:\\\\temp\"", "C:\\temp")] + public void QuotedValueKeepsItsTextExactly(string instance, string value) + { + UtilCollection c = new(instance); + Assert.True(c.IsValue()); + Assert.Equal(value, c.ToString()); + } + + [Fact] + public void QuotedValueInASetIsOneElement() + { + UtilCollection c = new("{\"a,b\", c}"); + Assert.Equal(2, c.Count()); + Assert.True(c.Contains(new UtilCollection("\"a,b\""))); + Assert.True(c.Contains(new UtilCollection("c"))); + } + + [Fact] + public void QuotingAPlainValueChangesNothing() + { + Assert.Equal(new UtilCollection("{a,b}"), new UtilCollection("{\"a\", \"b\"}")); + Assert.Equal(new UtilCollection("{a,b}").GetHashCode(), new UtilCollection("{\"b\",a}").GetHashCode()); + Assert.Equal("{a,b}", new UtilCollection("{\"a\",\"b\"}").ToString()); + } + + [Fact] + public void SetsWithQuotedValuesCompareByValue() + { + UtilCollection x = new("{\"a,b\",\"c d\"}"); + UtilCollection y = new("{\"c d\", \"a,b\"}"); + Assert.Equal(x, y); + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + Assert.NotEqual(x, new UtilCollection("{a,b,c d}")); + } + + [Theory] + [InlineData("{\"a,b\",c}", "{\"a,b\",c}")] + // A backslash means nothing outside quotes, so C:\temp needs none. + [InlineData("(\"say \\\"hi\\\"\", \"C:\\\\temp\")", "(\"say \\\"hi\\\"\",C:\\temp)")] + [InlineData("(\"a\\\\\\\"b, c\")", "(\"a\\\\\\\"b, c\")")] + [InlineData("(\" lead\",\"trail \",\"a\tb\",\"x : y\",\"\")", "(\" lead\",\"trail \",\"a\tb\",\"x : y\",\"\")")] + [InlineData("({\"{1}\"},(\"(2)\"))", "({\"{1}\"},(\"(2)\"))")] + public void ToStringQuotesOnlyWhatNeedsItAndReadsBack(string instance, string written) + { + UtilCollection c = new(instance); + Assert.Equal(written, c.ToString()); + Assert.Equal(c, new UtilCollection(c.ToString())); + } + + [Theory] + [InlineData("({New York, x1:True, a[1]}, (1,2))")] + [InlineData("{C:\\temp, 5, -3.5}")] + public void ValuesThatDontNeedQuotesPrintAsBefore(string instance) + { + UtilCollection c = new(instance); + Assert.DoesNotContain("\"", c.ToString()); + Assert.Equal(c, new UtilCollection(c.ToString())); + } + + [Theory] + [InlineData("\"abc", 5, "closing \"", "end of input")] + [InlineData("{\"a,b}", 7, "closing \"", "end of input")] + [InlineData("\"a\\nb\"", 4, "\\\" or \\\\ after \\", "n")] + [InlineData("\"a\\", 4, "\\\" or \\\\ after \\", "end of input")] + public void BadQuotesAreSyntaxErrors(string instance, int position, string expected, string found) + { + SpadeParseException e = Assert.Throws(() => new UtilCollection(instance)); + Assert.Equal(position, e.Position); + Assert.Equal(expected, e.Expected); + Assert.Equal(found, e.Found); + } + + [Theory] + [InlineData("{ab\"c\"}", 4, ", or }")] + [InlineData("{\"a\"b}", 5, ", or }")] + [InlineData("ab\"c\"", 3, "end of input")] + public void AQuoteCantBePartOfAnUnquotedValue(string instance, int position, string expected) + { + SpadeParseException e = Assert.Throws(() => new UtilCollection(instance)); + Assert.Equal(position, e.Position); + Assert.Equal(expected, e.Expected); + } + + [Fact] + public void QuotedValuesPassIsString() + { + StringParser parser = new("{(x,y) | x is string, y is string}"); + parser.parse("(\"hello, world\", \"{not a set}\")"); + Assert.Equal("hello, world", parser["x"].ToString()); + Assert.Equal("{not a set}", parser["y"].ToString()); + } + + [Fact] + public void QuotedNumberIsStillAnInt() + { + StringParser parser = new("{(K,S) | K is int, S subset int}"); + parser.parse("(\"5\", {\"1\", 2})"); + Assert.Equal(5, parser["K"].parseInt()); + Assert.Equal(2, parser["S"].Count()); + } + + [Fact] + public void QuotedValuesAreMembersByValue() + { + StringParser parser = new("{(N,E) | N is set, E subset N cross N}"); + parser.parse("({\"New York, NY\", Boston}, {(\"New York, NY\", Boston)})"); + Assert.Equal(2, parser["N"].Count()); + } +} diff --git a/tests/SPADE.Tests/SPADE.Tests.csproj b/tests/SPADE.Tests/SPADE.Tests.csproj new file mode 100644 index 0000000..2073855 --- /dev/null +++ b/tests/SPADE.Tests/SPADE.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + Exe + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/tests/SPADE.Tests/SetOperationTests.cs b/tests/SPADE.Tests/SetOperationTests.cs new file mode 100644 index 0000000..7c1b2a7 --- /dev/null +++ b/tests/SPADE.Tests/SetOperationTests.cs @@ -0,0 +1,101 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// union, intersection and cross in a definition's set expressions, and which part a failure names. +public class SetOperationTests +{ + private const string Union = "{(A,B,C) | A is set, B is set, C subset A union B}"; + private const string Intersection = "{(A,B,C) | A is set, B is set, C subset A intersection B}"; + + private static StringParser Parse(string grammar, string instance) + { + StringParser parser = new StringParser(grammar); + parser.parse(instance); + return parser; + } + + private static SpadeParseException Fails(string grammar, string instance) + { + return Assert.Throws(() => new StringParser(grammar).parse(instance)); + } + + [Theory] + [InlineData("({1},{2},{1,2})")] + [InlineData("({1},{2},{2})")] + [InlineData("({1,2},{2,3},{})")] + public void UnionAcceptsElementsOfEitherSide(string instance) + { + StringParser parser = Parse(Union, instance); + Assert.Equal(new UtilCollection(instance)[2], parser["C"]); + } + + // Missing from both sides; the left part is the one named. + [Fact] + public void UnionRejectsElementsOfNeitherSide() + { + SpadeParseException e = Fails(Union, "({1},{2},{1,3})"); + + Assert.Equal("Constraint C subset A union B failed: 3 is not a member", e.Message); + Assert.Equal("C", e.Part); + Assert.Equal("3", e.Element); + Assert.Equal("3", e.MissingValue); + Assert.Equal("A", e.ExpectedIn); + } + + [Fact] + public void UnionWithIntAcceptsIntegers() + { + const string grammar = "{(N,E) | N is set, E subset N union int}"; + Assert.Equal(new UtilCollection("{a,3,-4}"), Parse(grammar, "({a},{a,3,-4})")["E"]); + + SpadeParseException e = Fails(grammar, "({a},{a,3,b})"); + Assert.Equal("b", e.Element); + Assert.Equal("N", e.ExpectedIn); + } + + [Fact] + public void IntersectionAcceptsElementsOfBothSides() + { + StringParser parser = Parse(Intersection, "({1,2},{2,3},{2})"); + Assert.Equal(new UtilCollection("{2}"), parser["C"]); + } + + // The first side the element is missing from is named. + [Theory] + [InlineData("({1,2},{2,3},{1})", "1", "B")] + [InlineData("({1,2},{2,3},{3})", "3", "A")] + [InlineData("({1,2},{2,3},{4})", "4", "A")] + public void IntersectionNamesTheSideTheElementIsMissingFrom(string instance, string element, string expectedIn) + { + SpadeParseException e = Fails(Intersection, instance); + + Assert.Equal($"Constraint C subset A intersection B failed: {element} is not a member", e.Message); + Assert.Equal("C", e.Part); + Assert.Equal(element, e.MissingValue); + Assert.Equal(expectedIn, e.ExpectedIn); + } + + [Fact] + public void CrossNamesTheMissingHalfOfAPair() + { + SpadeParseException e = Fails("{(N,E) | N is set, E subset N cross N}", "({a},{(a,b)})"); + + Assert.Equal("(a,b)", e.Element); + Assert.Equal("b", e.MissingValue); + Assert.Equal("N", e.ExpectedIn); + } + + // A value is not a pair, so there is no missing half to name. + [Fact] + public void CrossNamesNoMissingPieceForANonPair() + { + SpadeParseException e = Fails("{(N,E) | N is set, E subset N cross N}", "({a},{b})"); + + Assert.Equal("E", e.Part); + Assert.Equal("b", e.Element); + Assert.Null(e.MissingValue); + Assert.Null(e.ExpectedIn); + } +} diff --git a/tests/SPADE.Tests/TypeConstraintTests.cs b/tests/SPADE.Tests/TypeConstraintTests.cs new file mode 100644 index 0000000..cf2e42a --- /dev/null +++ b/tests/SPADE.Tests/TypeConstraintTests.cs @@ -0,0 +1,170 @@ +using System.Globalization; +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// "is int", "is set" and "is list" must check the type they name (#10). +public class TypeConstraintTests +{ + private static void Parse(string grammar, string instance) + { + new StringParser(grammar).parse(instance); + } + + [Theory] + [InlineData("3")] + [InlineData("-12")] + [InlineData("0")] + public void IsIntAcceptsIntegers(string instance) + { + Parse("{N | N is int}", instance); + } + + [Theory] + [InlineData("abc")] + [InlineData("1.5")] + [InlineData("{1}")] + [InlineData("(1,2)")] + public void IsIntRejectsNonIntegers(string instance) + { + Assert.Throws(() => Parse("{N | N is int}", instance)); + } + + [Fact] + public void IsIntChecksNestedFields() + { + string grammar = "{((N,E),K) | N is set, E subset N unorderedcross N, K is int}"; + Parse(grammar, "(({1,2},{{1,2}}),2)"); + Assert.Throws(() => Parse(grammar, "(({1,2},{{1,2}}),two)")); + } + + [Fact] + public void IsIntChecksInsideSetComprehension() + { + string grammar = "{(N,E) | N is set, E subset {(e, w) | e is N unorderedcross N, w is int}}"; + Parse(grammar, "({1,2,3},{({1,2},4),({2,3},5)})"); + Assert.Throws(() => Parse(grammar, "({1,2,3},{({1,2},heavy)})")); + } + + [Theory] + [InlineData("{1,2}")] + [InlineData("{}")] + public void IsSetAcceptsSets(string instance) + { + Parse("{S | S is set}", instance); + } + + [Theory] + [InlineData("balanced")] + [InlineData("(1,2)")] + public void IsSetRejectsValuesAndLists(string instance) + { + Assert.Throws(() => Parse("{S | S is set}", instance)); + } + + [Theory] + [InlineData("(1,2)")] + [InlineData("()")] + public void IsListAcceptsLists(string instance) + { + Parse("{L | L is list}", instance); + } + + [Theory] + [InlineData("balanced")] + [InlineData("{1,2}")] + public void IsListRejectsValuesAndSets(string instance) + { + Assert.Throws(() => Parse("{L | L is list}", instance)); + } + + [Fact] + public void SubsetIntStillChecksEveryElement() + { + Parse("{S | S subset int}", "{1,2,3}"); + Assert.Throws(() => Parse("{S | S subset int}", "{1,2,x}")); + } + + // "is string" accepts any single value and rejects a collection (#22). + [Theory] + [InlineData("kitten", "kitten")] + [InlineData("New York", "New York")] + [InlineData(" New York ", "New York")] + [InlineData("5", "5")] + [InlineData("q0", "q0")] + public void IsStringAcceptsSingleValues(string instance, string value) + { + StringParser parser = new("{S | S is string}"); + parser.parse(instance); + Assert.True(parser["S"].IsValue()); + Assert.Equal(value, parser["S"].ToString()); + } + + [Theory] + [InlineData("{S | S is string}", "{a,b}", "{a,b}")] + [InlineData("{S | S is string}", "{}", "{}")] + [InlineData("{S | S is string}", "(a,b)", "(a,b)")] + [InlineData("{S | S is string}", "()", "()")] + [InlineData("{S | S is multiset, S is string}", "{a,a}", "{a,a}")] + public void IsStringRejectsCollections(string grammar, string instance, string element) + { + SpadeParseException e = Assert.Throws(() => Parse(grammar, instance)); + Assert.Equal($"Constraint S is string failed: {element} is not a string", e.Message); + Assert.Equal("S", e.Part); + Assert.Equal(element, e.Element); + Assert.Null(e.MissingValue); + } + + [Fact] + public void IsStringChecksNestedFields() + { + string grammar = "{(x,y) | x is string, y is string}"; + Parse(grammar, "(kitten,sitting)"); + SpadeParseException e = Assert.Throws(() => Parse(grammar, "(kitten,{sitting})")); + Assert.Equal("y", e.Part); + } + + [Fact] + public void IsStringInADfaDefinition() + { + string grammar = "{((N,A,E,S,F),I) | N is set, A is set, E is N cross A cross N, S is string, F is set, I is string}"; + StringParser parser = new(grammar); + parser.parse("(({q0,q1},{0,1},{(q0,0,q1),(q1,1,q0)},q0,{q1}),0110)"); + Assert.Equal("q0", parser["S"].ToString()); + Assert.Equal("0110", parser["I"].ToString()); + Assert.Equal(PartKind.String, parser.Definition["S"].Kind); + Assert.Equal(PartKind.String, parser.Definition["I"].Kind); + + SpadeParseException e = Assert.Throws(() => Parse(grammar, "(({q0,q1},{0,1},{(q0,0,q1)},{q0},{q1}),0110)")); + Assert.Equal("Constraint S is string failed: {q0} is not a string", e.Message); + Assert.Equal("S", e.Part); + } + + [Fact] + public void IntsDoNotDependOnTheMachineCulture() + { + CultureInfo saved = CultureInfo.CurrentCulture; + try + { + // A culture whose minus sign is not '-' (some cultures use U+2212): "-5" must still be an int. + CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NegativeSign = "~"; + CultureInfo.CurrentCulture = culture; + + Parse("{N | N is int}", "-5"); + Parse("{S | S subset int}", "{-5, 3}"); + StringParser parser = new("{N | N is int}"); + parser.parse("-5"); + Assert.Equal(-5, parser["N"].parseInt()); + + SpadeParseException e = Assert.Throws(() => Parse("{N | N is int}", "~5")); + Assert.Equal("Constraint N is int failed: ~5 is not an integer", e.Message); + Assert.Throws(() => Parse("{S | S subset int}", "{~5}")); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } +} diff --git a/tests/SPADE.Tests/UnorderedCrossTests.cs b/tests/SPADE.Tests/UnorderedCrossTests.cs new file mode 100644 index 0000000..03fcfec --- /dev/null +++ b/tests/SPADE.Tests/UnorderedCrossTests.cs @@ -0,0 +1,68 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Undirected edges must be sets of nodes, such as {a,b} (#12). +public class UnorderedCrossTests +{ + private const string Graph = "{(N,E) | N is set, E subset N unorderedcross N}"; + private const string GraphWithK = "{((N,E),K) | N is set, E subset N unorderedcross N, K is int}"; + + private static StringParser Parse(string grammar, string instance) + { + StringParser parser = new StringParser(grammar); + parser.parse(instance); + return parser; + } + + [Theory] + [InlineData("({a,b,c},{{a,b},{b,c}})")] + [InlineData("({1,2,3},{{2,1},{3,1}})")] + [InlineData("({a,b},{})")] + public void AcceptsSetEdges(string instance) + { + StringParser parser = Parse(Graph, instance); + Assert.Equal(new UtilCollection(instance)[1], parser["E"]); + } + + [Fact] + public void AcceptsGraphWithK() + { + StringParser parser = Parse(GraphWithK, "(({1,2,3},{{1,2},{2,3}}),2)"); + Assert.Equal("2", parser["K"].ToString()); + } + + // A list edge is an ordered pair, which belongs to cross, not unorderedcross. + [Theory] + [InlineData("({a,b,c},{(a,b)})")] + [InlineData("({a,b,c},{{a,b},(b,c)})")] + public void RejectsListEdges(string instance) + { + Assert.Throws(() => Parse(Graph, instance)); + } + + [Fact] + public void CrossAcceptsListEdges() + { + StringParser parser = Parse("{(N,E) | N is set, E subset N cross N}", "({a,b,c},{(a,b),(b,a)})"); + Assert.Equal(2, parser["E"].Count()); + } + + [Theory] + [InlineData("({a,b,c},{{a,d}})")] + [InlineData("({a,b,c},{{x,y}})")] + public void RejectsEdgesWithUnknownNodes(string instance) + { + Assert.Throws(() => Parse(Graph, instance)); + } + + [Fact] + public void RejectsPlainValueEdgeAsConstraintFailure() + { + // A plain value is not an edge. It should fail the constraint, not + // crash while trying to enumerate the value. + Exception e = Assert.Throws(() => Parse(Graph, "({a,b},{a})")); + Assert.IsNotType(e); + } +} diff --git a/tests/SPADE.Tests/UtilCollectionEqualityTests.cs b/tests/SPADE.Tests/UtilCollectionEqualityTests.cs new file mode 100644 index 0000000..1dc1a52 --- /dev/null +++ b/tests/SPADE.Tests/UtilCollectionEqualityTests.cs @@ -0,0 +1,61 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Equal collections must hash the same, or sets of sets lose lookups (#9). +public class UtilCollectionEqualityTests +{ + private static UtilCollection C(string s) => new UtilCollection(s); + + [Theory] + [InlineData("{1,2}", "{2,1}")] + [InlineData("{1,2,3}", "{3,1,2}")] + [InlineData("{{1,2},{3}}", "{{3},{2,1}}")] + [InlineData("{(1,2),(3,4)}", "{(3,4),(1,2)}")] + [InlineData("({1,2},4)", "({2,1},4)")] + [InlineData("{}", "{}")] + public void EqualCollectionsHaveEqualHashCodes(string a, string b) + { + Assert.Equal(C(a), C(b)); + Assert.Equal(C(a).GetHashCode(), C(b).GetHashCode()); + } + + [Fact] + public void SetOfSetsContainsReorderedMember() + { + Assert.True(C("{{1,2}}").Contains(C("{2,1}"))); + } + + [Fact] + public void SetOfSetsDropsReorderedDuplicate() + { + Assert.Equal(1, C("{{1,2},{2,1}}").Count()); + } + + [Fact] + public void IntersectMatchesReorderedSets() + { + Assert.Equal(1, C("{{1,2}}").Intersect(C("{{2,1}}")).Count()); + } + + [Fact] + public void UnionMergesReorderedSets() + { + Assert.Equal(1, C("{{1,2}}").Union(C("{{2,1}}")).Count()); + } + + [Theory] + [InlineData("(1,2)", "(2,1)")] + [InlineData("{1,2}", "(1,2)")] + [InlineData("{}", "()")] + [InlineData("{}", "x")] + [InlineData("()", "x")] + [InlineData("{1}", "1")] + [InlineData("{1,2}", "{1,2,3}")] + public void DifferentCollectionsAreNotEqual(string a, string b) + { + Assert.NotEqual(C(a), C(b)); + Assert.NotEqual(C(b), C(a)); + } +} diff --git a/tests/SPADE.Tests/UtilCollectionFreezeTests.cs b/tests/SPADE.Tests/UtilCollectionFreezeTests.cs new file mode 100644 index 0000000..70312c4 --- /dev/null +++ b/tests/SPADE.Tests/UtilCollectionFreezeTests.cs @@ -0,0 +1,250 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// A collection freezes once it is hashed, so it can't end up in a set under a stale hash. +public class UtilCollectionFreezeTests +{ + private static UtilCollection C(string s) => new UtilCollection(s); + + [Fact] + public void AddThrowsOnceInAHashSet() + { + UtilCollection item = C("{1,2}"); + HashSet outer = new() { item }; + + InvalidOperationException e = Assert.Throws(() => item.Add(C("3"))); + Assert.Contains("has been hashed", e.Message); + Assert.Equal(2, item.Count()); + Assert.Contains(C("{2,1}"), outer); + } + + [Fact] + public void AddThrowsOnceUsedAsADictionaryKey() + { + UtilCollection key = C("(1,2)"); + Dictionary counts = new() { [key] = 1 }; + + Assert.Throws(() => key.Add(C("3"))); + Assert.Equal(1, counts[C("(1,2)")]); + } + + [Fact] + public void AddingToASetFreezesTheElement() + { + UtilCollection outer = C("{}"); + UtilCollection item = C("(1,2)"); + outer.Add(item); + + Assert.Throws(() => item.Add(C("3"))); + Assert.True(outer.Contains(C("(1,2)"))); + } + + [Fact] + public void ChildOfAHashedListIsFrozen() + { + UtilCollection child = C("{1}"); + UtilCollection pair = new UtilCollection(new List { child, C("x") }); + _ = new HashSet { pair }; + + Assert.Throws(() => child.Add(C("2"))); + } + + [Fact] + public void GrandchildOfAHashedSetIsFrozen() + { + UtilCollection outer = C("{({1},a)}"); + _ = outer.GetHashCode(); + UtilCollection pair = outer.ToList()[0]; + UtilCollection grandchild = pair[0]; + + Assert.Throws(() => grandchild.Add(C("2"))); + } + + [Fact] + public void IndexerSetterThrowsOnAHashedList() + { + UtilCollection list = C("(1,2)"); + _ = new HashSet { list }; + + Assert.Throws(() => list[0] = C("3")); + Assert.Equal("(1,2)", list.ToString()); + } + + [Fact] + public void HashIsTheSameOnEveryCall() + { + UtilCollection item = C("{(1,2),{3}}"); + Assert.Equal(item.GetHashCode(), item.GetHashCode()); + Assert.Equal(C("{{3},(1,2)}").GetHashCode(), item.GetHashCode()); + } + + [Fact] + public void ListConstructorCopiesItsArgument() + { + List source = new() { C("1"), C("2") }; + UtilCollection list = new UtilCollection(source); + source.Add(C("3")); + source[0] = C("9"); + + Assert.Equal("(1,2)", list.ToString()); + } + + [Fact] + public void SetConstructorCopiesItsArgument() + { + HashSet source = new() { C("1"), C("2") }; + UtilCollection set = new UtilCollection(source); + source.Add(C("3")); + source.Remove(C("1")); + + Assert.Equal(C("{1,2}"), set); + } + + [Fact] + public void ToListOfAListIsACopy() + { + UtilCollection list = C("(1,2)"); + _ = list.GetHashCode(); + list.ToList().Add(C("3")); + + Assert.Equal(2, list.Count()); + } + + [Fact] + public void BuildingFullyThenInsertingWorks() + { + UtilCollection edges = C("{}"); + foreach ((string a, string b) in new[] { ("1", "2"), ("2", "3"), ("2", "1") }) + { + UtilCollection edge = C("{}"); + edge.Add(C(a)); + edge.Add(C(b)); + edges.Add(edge); + } + + // {1,2} and {2,1} are the same edge. + Assert.Equal(2, edges.Count()); + Assert.True(edges.Contains(C("{2,1}"))); + Assert.True(edges.Contains(C("{3,2}"))); + Assert.False(edges.Contains(C("{1,3}"))); + } + + [Fact] + public void UnhashedCollectionsCanStillChange() + { + UtilCollection set = C("{1}"); + set.Add(C("2")); + UtilCollection list = C("(1,2)"); + list.Add(C("3")); + list[0] = C("0"); + + Assert.Equal(C("{1,2}"), set); + Assert.Equal("(0,2,3)", list.ToString()); + } + + [Fact] + public void HashingFromManyThreadsAgrees() + { + // Each round starts unfrozen, so the threads race to compute and cache the hash. + for (int round = 0; round < 200; round++) + { + UtilCollection set = C("{({1,2},{3,4}),({5,6},(7,8)),9,{10,{11,12}}}"); + int expected = C("{({1,2},{3,4}),({5,6},(7,8)),9,{10,{11,12}}}").GetHashCode(); + int[] seen = new int[8]; + Parallel.For(0, seen.Length, i => seen[i] = set.GetHashCode()); + + Assert.All(seen, h => Assert.Equal(expected, h)); + } + } + + [Fact] + public void FreezeLocksTheCollectionAndEverythingInside() + { + UtilCollection child = C("(1,2)"); + UtilCollection list = new UtilCollection(new List { child, C("{3}") }); + + Assert.Same(list, list.Freeze()); + Assert.True(list.IsFrozen()); + Assert.True(child.IsFrozen()); + Assert.Throws(() => list.Add(C("4"))); + Assert.Throws(() => child[0] = C("0")); + Assert.Equal("((1,2),{3})", list.ToString()); + } + + [Fact] + public void FreezeTwiceIsHarmlessAndKeepsTheHash() + { + UtilCollection set = C("{1,{2,3}}"); + int hash = set.Freeze().GetHashCode(); + + Assert.Equal(hash, set.Freeze().GetHashCode()); + Assert.Equal(C("{{3,2},1}").GetHashCode(), hash); + } + + [Fact] + public void NewCollectionsAreNotFrozen() + { + Assert.False(C("{1,2}").IsFrozen()); + Assert.False(C("(1,2)").IsFrozen()); + } + + [Fact] + public void EqualsDoesNotFreeze() + { + UtilCollection set = C("{1}"); + Assert.Equal(C("{1}"), set); + set.Add(C("2")); + + Assert.Equal(2, set.Count()); + } + + // --- Multisets (#1) --- + + private static UtilCollection M(string s) => new UtilCollection(s).AsMultiset(); + + [Fact] + public void AddThrowsOnAHashedMultiset() + { + UtilCollection multiset = M("{3,3,2}"); + HashSet outer = new() { multiset }; + + Assert.Throws(() => multiset.Add(C("3"))); + Assert.Equal(2, multiset.CountOf(C("3"))); + Assert.Contains(M("{2,3,3}"), outer); + } + + [Fact] + public void ElementsOfAHashedMultisetAreFrozen() + { + UtilCollection element = C("(1,2)"); + UtilCollection multiset = M("{}"); + multiset.Add(element); + multiset.Add(C("(1,2)")); + + // Counting an element hashes it, so it freezes as soon as it goes in. + Assert.Throws(() => element.Add(C("3"))); + Assert.Equal(2, multiset.CountOf(C("(1,2)"))); + } + + [Fact] + public void AsMultisetOfAHashedSetStillKeepsDuplicates() + { + UtilCollection set = C("{3,3,2}"); + _ = set.GetHashCode(); + + Assert.Throws(() => set.Add(C("3"))); + Assert.Equal(3, set.AsMultiset().Count()); + } + + [Fact] + public void UnhashedMultisetsCanStillChange() + { + UtilCollection multiset = M("{3}"); + multiset.Add(C("3")); + + Assert.Equal(2, multiset.CountOf(C("3"))); + Assert.Equal(M("{3,3}").GetHashCode(), multiset.GetHashCode()); + } +} diff --git a/tests/SPADE.Tests/UtilCollectionOperationTests.cs b/tests/SPADE.Tests/UtilCollectionOperationTests.cs new file mode 100644 index 0000000..78ee3ef --- /dev/null +++ b/tests/SPADE.Tests/UtilCollectionOperationTests.cs @@ -0,0 +1,144 @@ +using SPADE; +using Xunit; + +namespace SPADE.Tests; + +// Set operations, shape checks and the other UtilCollection helpers callers use on parsed parts. +public class UtilCollectionOperationTests +{ + private static UtilCollection C(string s) => new UtilCollection(s); + + [Fact] + public void SetOfStringSetsBuildsNestedSets() + { + UtilCollection built = new UtilCollection(new HashSet> + { + new HashSet { "1", "2" }, + new HashSet { "3" }, + }); + + Assert.True(built.IsUnordered()); + Assert.Equal(C("{{2,1},{3}}"), built); + Assert.True(built.Contains(C("{1,2}"))); + } + + [Fact] + public void UnionOfSetsHasEveryElementOnce() + { + Assert.Equal(C("{1,2,3}"), C("{1,2}").Union(C("{2,3}"))); + } + + [Fact] + public void UnionOfMultisetsKeepsTheLargerCount() + { + UtilCollection union = C("{1,1,2}").AsMultiset().Union(C("{1,2,2,3}").AsMultiset()); + + Assert.True(union.IsMultiset()); + Assert.Equal(C("{1,1,2,2,3}").AsMultiset(), union); + Assert.Equal(2, union.CountOf(C("1"))); + Assert.Equal(2, union.CountOf(C("2"))); + Assert.Equal(1, union.CountOf(C("3"))); + } + + [Fact] + public void UnionAndIntersectionRejectMixedKinds() + { + InvalidOperationException listUnion = Assert.Throws(() => C("{1}").Union(C("(1)"))); + Assert.Equal("Can't take the union of a set and a list", listUnion.Message); + + InvalidOperationException multisetUnion = Assert.Throws(() => C("{1}").Union(C("{1}").AsMultiset())); + Assert.Equal("Can't take the union of a set and a multiset", multisetUnion.Message); + + InvalidOperationException listIntersect = Assert.Throws(() => C("(1)").Intersect(C("{1}"))); + Assert.Equal("Can't take the intersection of a set and a list", listIntersect.Message); + + InvalidOperationException multisetIntersect = Assert.Throws(() => C("{1}").AsMultiset().Intersect(C("{1}"))); + Assert.Equal("Can't take the intersection of a set and a multiset", multisetIntersect.Message); + } + + // Lists have no union or intersection yet; saying so beats guessing at an order. + [Fact] + public void UnionAndIntersectionOfListsAreNotImplemented() + { + Assert.Throws(() => C("(1,2)").Union(C("(2,3)"))); + Assert.Throws(() => C("(1,2)").Intersect(C("(2,3)"))); + } + + [Fact] + public void CrossPairsEveryElementOfTheLeftWithEveryElementOfTheRight() + { + UtilCollection cross = C("{1,2}").Cross(C("{a,b}")); + + Assert.True(cross.IsUnordered()); + Assert.Equal(C("{(1,a),(1,b),(2,a),(2,b)}"), cross); + } + + [Fact] + public void CrossOfListsIsASetOfPairs() + { + UtilCollection cross = C("(1,1)").Cross(C("(x)")); + + // (1,x) twice collapses to one pair, since the result is a set. + Assert.Equal(C("{(1,x)}"), cross); + Assert.Equal(C("(1,x)"), cross.ToList().Single()); + } + + [Fact] + public void CrossWithAnEmptySetIsEmpty() + { + Assert.Equal(0, C("{1,2}").Cross(C("{}")).Count()); + } + + [Fact] + public void AssertPairAcceptsOnlyListsOfTheGivenLength() + { + Assert.Null(Record.Exception(() => C("(1,2)").assertPair())); + Assert.Null(Record.Exception(() => C("(1,2,3)").assertPair(3))); + + Assert.Equal("Not a list of correct length", Assert.Throws(() => C("(1,2,3)").assertPair()).Message); + Assert.Equal("Not a list of correct length", Assert.Throws(() => C("{1,2}").assertPair()).Message); + Assert.Equal("Not a list of correct length", Assert.Throws(() => C("(1,2)").assertPair(3)).Message); + } + + [Fact] + public void AssertCountChecksTheNumberOfElements() + { + Assert.Null(Record.Exception(() => C("{1,2,3}").assertCount(3))); + Assert.Null(Record.Exception(() => C("(1,1)").assertCount(2))); + + // A set drops its duplicate, so {1,1} has one element. + InvalidOperationException e = Assert.Throws(() => C("{1,1}").assertCount(2)); + Assert.Equal("collection has incorrect length", e.Message); + } + + [Fact] + public void AssertOrderedAndUnorderedTellListsFromSets() + { + Assert.Null(Record.Exception(() => C("(1,2)").assertOrdered())); + Assert.Null(Record.Exception(() => C("{1,2}").assertUnordered())); + + Assert.Equal("Not ordered", Assert.Throws(() => C("{1,2}").assertOrdered()).Message); + Assert.Equal("Ordered", Assert.Throws(() => C("(1,2)").assertUnordered()).Message); + } + + [Theory] + [InlineData("{1,2}", "{2,1}", false)] + [InlineData("(1,2)", "(1,2)", false)] + [InlineData("(1,2)", "(2,1)", true)] + [InlineData("{1,2}", "(1,2)", true)] + [InlineData("{1,2}", "{1}", true)] + [InlineData("a", "a", false)] + [InlineData("a", "b", true)] + public void NotEqualsIsTheOppositeOfEquals(string a, string b, bool notEqual) + { + Assert.Equal(notEqual, C(a).NotEquals(C(b))); + Assert.Equal(!notEqual, C(a).Equals(C(b))); + } + + [Fact] + public void NotEqualToNullOrAnotherType() + { + Assert.True(C("{1}").NotEquals(null)); + Assert.True(C("1").NotEquals("1")); + } +}