Skip to content

Commit

Permalink
Matter 1.4 constraint support
Browse files Browse the repository at this point in the history
Moves sign handling from lexer to parser which I would consider a bug fix although it didn't affect any expressions
prior to 1.4.

Adds support for codepoint limits on strings.  This is a new feature for 1.4.

Adds additional tests.
  • Loading branch information
lauckhart committed Dec 30, 2024
1 parent 06876c6 commit b6e92e0
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 58 deletions.
113 changes: 99 additions & 14 deletions packages/model/src/aspects/Constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { Lexer } from "#parser/Lexer.js";
import { BasicToken } from "#parser/Token.js";
import { TokenStream } from "#parser/TokenStream.js";
import { camelize } from "@matter/general";
import { FieldValue } from "../common/index.js";
Expand All @@ -22,6 +23,7 @@ export class Constraint extends Aspect<Constraint.Definition> implements Constra
declare max?: Constraint.Expression;
declare in?: FieldValue;
declare entry?: Constraint;
declare cpMax?: number;
declare parts?: Constraint[];

/**
Expand Down Expand Up @@ -76,6 +78,9 @@ export class Constraint extends Aspect<Constraint.Definition> implements Constra
if (ast.entry !== undefined) {
this.entry = new Constraint(ast.entry);
}
if (ast.cpMax !== undefined) {
this.cpMax = ast.cpMax;
}
if (ast.parts !== undefined) {
this.parts = ast.parts.map(p => new Constraint(p));
}
Expand Down Expand Up @@ -222,6 +227,11 @@ export namespace Constraint {
*/
entry?: Ast;

/**
* Constraint on codepoints in a string.
*/
cpMax?: number;

/**
* List of sub-constraints in a sequence.
*/
Expand Down Expand Up @@ -258,18 +268,27 @@ namespace Serializer {
if (ast.entry) {
return `${serializeAtom(ast)}[${serialize(ast.entry)}]`;
}
if (ast.cpMax) {
return `${serializeAtom(ast)}{${ast.cpMax}}`;
}
return serializeAtom(ast);
}

function serializeValue(value: Constraint.Expression): string {
function serializeValue(value: Constraint.Expression, inExpr = false): string {
if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date) {
return FieldValue.serialize(value);
}

switch (value.type) {
case "+":
case "-":
return `(${serializeValue(value.lhs)} ${value.type} ${serializeValue(value.rhs)})`;
const sum = `${serializeValue(value.lhs, true)} ${value.type} ${serializeValue(value.rhs, true)}`;
if (inExpr) {
// Ideally only add parenthesis if precedence requires. But nested expressions are not used
// anywhere as yet (and probably won't be) so don't try to be fancy, just correct
return `(${sum})`;
}
return sum;

default:
return FieldValue.serialize(value);
Expand Down Expand Up @@ -351,28 +370,63 @@ namespace Parser {
}

function parsePart(): Constraint.Ast | undefined {
const result = parsePartWithoutArray();
const result = parsePartWithoutSubconstraint();

if (result !== undefined && tokens.token?.type === "[") {
tokens.next();
if (result === undefined) {
return result;
}

const entry = parseParts();
switch (tokens.token?.type) {
case "[":
{
tokens.next();

if (tokens.token?.type !== ("]" as any)) {
constraint.error("MISSING_ENTRY_END", 'Entry constraint does not end with "]"');
}
const entry = parseParts();

tokens.next();
if (tokens.token?.type !== ("]" as any)) {
constraint.error("MISSING_ENTRY_END", 'Entry constraint does not end with "]"');
}

if (entry !== undefined) {
result.entry = entry;
}
tokens.next();

if (entry !== undefined) {
result.entry = entry;
}
}
break;

case "{":
{
tokens.next();

if (tokens.token?.type !== ("value" as any)) {
constraint.error(
"MISSING_CODEPOINT_MAX",
"Codepoint constraint does not specify maximum codepoint length",
);
if (tokens.peeked?.type === "}") {
tokens.next();
}
} else {
result.cpMax = FieldValue.numericValue(
(tokens.token as unknown as BasicToken.Number).value,
);
tokens.next();
}

if (tokens.token?.type !== ("}" as any)) {
constraint.error("MISSING_CODEPOINT_END", 'Codepoint constraint does not end with "}"');
}

tokens.next();
}
break;
}

return result;
}

function parsePartWithoutArray(): Constraint.Ast | undefined {
function parsePartWithoutSubconstraint(): Constraint.Ast | undefined {
const { token } = tokens;

if (!token) {
Expand Down Expand Up @@ -486,6 +540,37 @@ namespace Parser {
tokens.next();
return ref;

case "-":
case "+": {
tokens.next();

let number = tokens.token?.type === "value" ? tokens.token.value : undefined;

if (number !== undefined) {
tokens.next();

if (token.type === "-") {
if (typeof number === "number") {
number *= -1;
} else if (
FieldValue.is(number, FieldValue.percent) ||
FieldValue.is(number, FieldValue.celsius)
) {
(number as FieldValue.Percent | FieldValue.Celsius).value *= -1;
} else {
number = undefined;
}
}
}

if (number === undefined) {
constraint.error("MISSING_NUMBER", `Unary "${token.type}" not followed by numeric value`);
return;
}

return number;
}

case "(": {
tokens.next();

Expand Down
60 changes: 22 additions & 38 deletions packages/model/src/parser/Lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,33 +117,7 @@ function* lex(
return;
}

function tokenizeNumber(sign: number) {
markStart();
if (sign === -1) {
// Skip "-" prefix
next();
}

if (current.value === "0") {
if (peeked.value === "x") {
next();
next();
return tokenizeDigits(16, sign, hexadecimalValueOf);
} else if (peeked.value === "b") {
next();
next();
return tokenizeDigits(2, sign, binaryValueOf);
}
}

return tokenizeDigits(10, sign, decimalValueOf);
}

function tokenizeDigits(
base: number,
sign: number,
valueOf: (digit: string[1] | undefined) => number | undefined,
): BasicToken {
function tokenizeDigits(base: number, valueOf: (digit: string[1] | undefined) => number | undefined): BasicToken {
// The first digit may not actually be a digit if number is hexadecimal or binary
let num = valueOf(current.value);
if (num === undefined) {
Expand All @@ -161,8 +135,6 @@ function* lex(
num = num * base + digitValue;
}

num *= sign;

if (base === 10 && peeked.value === ".") {
next();
let fraction = "";
Expand Down Expand Up @@ -204,22 +176,34 @@ function* lex(
case "]":
case "(":
case ")":
case "{":
case "}":
case "-":
case "+":
case "/":
case "*":
yield { type: current.value, startLine: line, startChar: char };
break;

case "-":
if (peeked.value !== undefined && peeked.value >= "0" && peeked.value <= "9") {
yield tokenizeNumber(-1);
} else {
yield { type: current.value, startLine: line, startChar: char };
case "0":
markStart();

if (current.value === "0") {
if (peeked.value === "x") {
next();
next();
yield tokenizeDigits(16, hexadecimalValueOf);
break;
}

if (peeked.value === "b") {
next();
next();
yield tokenizeDigits(2, binaryValueOf);
}
}
break;

case "0":
yield tokenizeNumber(1);
yield tokenizeDigits(10, decimalValueOf);
break;

case "1":
Expand All @@ -231,7 +215,7 @@ function* lex(
case "7":
case "8":
case "9":
yield tokenizeDigits(10, 1, decimalValueOf);
yield tokenizeDigits(10, decimalValueOf);
break;

case "!":
Expand Down
2 changes: 2 additions & 0 deletions packages/model/src/parser/Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export namespace BasicToken {
| "]"
| "("
| ")"
| "{"
| "}"
| "-"
| "+"
| "*"
Expand Down
13 changes: 11 additions & 2 deletions packages/model/test/aspects/ConstraintTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin
["0", { value: 0 }],
["desc", { desc: true }],
["4", { value: 4 }],
["-4", { value: -4 }],
["+4", { value: 4 }, "4"],
["4%", { value: { type: "percent", value: 4 } }],
["4°C", { value: { type: "celsius", value: 4 } }],
["3.141592", { value: 3.141592 }],
Expand All @@ -19,8 +21,15 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin
["0x4 to 0x44", { min: 4, max: 68 }, "4 to 68"],
["0xff to 0xffff", { min: 255, max: 65535 }, "255 to 65535"],
["4[44]", { value: 4, entry: { value: 44 } }],
["4{44}", { value: 4, cpMax: 44 }],
["4, 44", { parts: [{ value: 4 }, { value: 44 }] }],
["in foo", { in: { type: "reference", name: "foo" } }],
["-2.5°C to 2.5°C", { min: { type: "celsius", value: -2.5 }, max: { type: "celsius", value: 2.5 } }],
[
"0 to NumberOfPositions-1",
{ min: 0, max: { type: "-", lhs: { type: "reference", name: "numberOfPositions" }, rhs: 1 } },
"0 to numberOfPositions - 1",
],
[
"4[44, 444], 5[max 55, min 555]",
{
Expand All @@ -41,7 +50,7 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin
},
],
[
"(foo - 2)",
"foo - 2",
{
value: {
type: "-",
Expand All @@ -54,7 +63,7 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin
},
],
[
"4 to (foo + 2)",
"4 to foo + 2",
{
min: 4,

Expand Down
37 changes: 34 additions & 3 deletions packages/node/src/behavior/state/validation/constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Constraint, FieldValue, Metatype, ValueModel } from "#model";
import { ConstraintError } from "../../errors.js";
import { ValueSupervisor } from "../../supervision/ValueSupervisor.js";
import { Val } from "../Val.js";
import { assertArray, assertBoolean, assertNumeric, assertSequence } from "./assertions.js";
import { assertArray, assertBoolean, assertNumeric, assertSequence, assertString } from "./assertions.js";

/**
* Creates a function that validates values based on the constraint in the
Expand Down Expand Up @@ -62,15 +62,46 @@ export function createConstraintValidator(
}
};

case Metatype.string:
case Metatype.string: {
const validateLength: ValueSupervisor.Validate = (value: Val, _session, location) => {
assertSequence(value, location);
if (!constraint.test(value.length, location.siblings)) {
throw new ConstraintError(
schema,
location,
`String length of ${value.length} is not within bounds defined by constraint`,
);
}
};

const { cpMax } = constraint;
if (cpMax === undefined) {
return validateLength;
}

return (value: Val, _session, location) => {
validateLength(value, _session, location);
assertString(value, location);

const codepointCount = [...value].length;
if (codepointCount > cpMax) {
throw new ConstraintError(
schema,
location,
`Codepoint count of ${codepointCount} is not within bounds defined by constraint`,
);
}
};
}

case Metatype.bytes:
return (value: Val, _session, location) => {
assertSequence(value, location);
if (!constraint.test(value.length, location.siblings)) {
throw new ConstraintError(
schema,
location,
`Lenght of value ${value} is not within bounds defined by constraint`,
`Byte length of ${value.length} is not within bounds defined by constraint`,
);
}
};
Expand Down
Loading

0 comments on commit b6e92e0

Please sign in to comment.