Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Matter 1.4 constraint support #1592

Merged
merged 1 commit into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The main work (all changes without a GitHub username in brackets in the below li

- @matter/model
- Feature: The constraint evaluator now supports simple mathematical expressions
- Feature: The constraint evaluator now supports limits on the number of Unicode codepoints in a string

- @project-chip/matter.js
- Feature: (Breaking) Added Fabric Label for Controller as required property to initialize the Controller
Expand Down
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
Loading