@m6d/cortex-cli 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { datasetIdentifierSchema } from "../datasets/index";
|
|
3
3
|
import { canCarrySecret } from "../egress-policy/index";
|
|
4
|
+
import { isRecord } from "../json/index";
|
|
5
|
+
import { linearPattern } from "../pattern/index";
|
|
4
6
|
import { triggerScheduleSchema } from "../schedule/index";
|
|
5
7
|
import {
|
|
6
8
|
displayNameSchema,
|
|
@@ -677,47 +679,251 @@ const propertyOrderSchema = z
|
|
|
677
679
|
.refine((names) => new Set(names).size === names.length, "a name repeats")
|
|
678
680
|
.optional();
|
|
679
681
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
682
|
+
const countSchema = z.number().int().min(0).optional();
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The JSON Schema keywords that bound one value beyond its type and enum:
|
|
686
|
+
* a string's `pattern` (one RE2 can match, as Cortex checks it) and lengths,
|
|
687
|
+
* a scalar's `format` (kept for the model, not checked), a number's range and step, an
|
|
688
|
+
* array's length and uniqueness, and a scalar's `const`.
|
|
689
|
+
*/
|
|
690
|
+
export const valueKeywordsSchema = z.strictObject({
|
|
691
|
+
pattern: z
|
|
692
|
+
.string()
|
|
693
|
+
.min(1)
|
|
694
|
+
.max(1_000)
|
|
695
|
+
.refine((pattern) => linearPattern(pattern) !== undefined, "a pattern Cortex cannot match")
|
|
696
|
+
.optional(),
|
|
697
|
+
minLength: countSchema,
|
|
698
|
+
maxLength: countSchema,
|
|
699
|
+
format: z.string().trim().min(1).max(100).optional(),
|
|
700
|
+
minimum: z.number().optional(),
|
|
701
|
+
maximum: z.number().optional(),
|
|
702
|
+
exclusiveMinimum: z.number().optional(),
|
|
703
|
+
exclusiveMaximum: z.number().optional(),
|
|
704
|
+
multipleOf: z.number().positive().optional(),
|
|
705
|
+
minItems: countSchema,
|
|
706
|
+
maxItems: countSchema,
|
|
707
|
+
uniqueItems: z.boolean().optional(),
|
|
708
|
+
const: z.union([z.string().max(4_000), z.number(), z.boolean()]).optional(),
|
|
688
709
|
});
|
|
710
|
+
export type ValueKeywords = z.infer<typeof valueKeywordsSchema>;
|
|
711
|
+
|
|
712
|
+
const NUMERIC_TYPES = ["number", "integer"];
|
|
713
|
+
|
|
714
|
+
/** The types each value keyword bounds; on any other it means nothing. */
|
|
715
|
+
export const VALUE_KEYWORD_TYPES = {
|
|
716
|
+
pattern: ["string"],
|
|
717
|
+
minLength: ["string"],
|
|
718
|
+
maxLength: ["string"],
|
|
719
|
+
format: SCALAR_TYPES,
|
|
720
|
+
minimum: NUMERIC_TYPES,
|
|
721
|
+
maximum: NUMERIC_TYPES,
|
|
722
|
+
exclusiveMinimum: NUMERIC_TYPES,
|
|
723
|
+
exclusiveMaximum: NUMERIC_TYPES,
|
|
724
|
+
multipleOf: NUMERIC_TYPES,
|
|
725
|
+
minItems: ["array"],
|
|
726
|
+
maxItems: ["array"],
|
|
727
|
+
uniqueItems: ["array"],
|
|
728
|
+
const: SCALAR_TYPES,
|
|
729
|
+
} satisfies Record<keyof ValueKeywords, ReadonlyArray<string>>;
|
|
730
|
+
|
|
731
|
+
/** The types a rule tests a value for: `"null"` too, since a rule may tell a null apart. */
|
|
732
|
+
const ruleTypeSchema = z.enum([...SCALAR_TYPES, "object", "array", "null"]);
|
|
733
|
+
type RuleType = z.infer<typeof ruleTypeSchema>;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* An object's cross-field rules: which properties go together (`anyOf`,
|
|
737
|
+
* `oneOf`, `allOf`, `not`, `if`/`then`/`else`, `dependentRequired`,
|
|
738
|
+
* `dependentSchemas`) and how many it holds. A rule names the object's own
|
|
739
|
+
* properties only.
|
|
740
|
+
*/
|
|
741
|
+
export type ObjectRules = {
|
|
742
|
+
anyOf?: Condition[];
|
|
743
|
+
oneOf?: Condition[];
|
|
744
|
+
allOf?: Condition[];
|
|
745
|
+
not?: Condition;
|
|
746
|
+
if?: Condition;
|
|
747
|
+
then?: Condition;
|
|
748
|
+
else?: Condition;
|
|
749
|
+
dependentRequired?: Record<string, string[]>;
|
|
750
|
+
dependentSchemas?: Record<string, Condition>;
|
|
751
|
+
minProperties?: number;
|
|
752
|
+
maxProperties?: number;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* A schema a rule checks its object against, in the part of JSON Schema a
|
|
757
|
+
* rule reads: what it requires, its properties' types, enums and value
|
|
758
|
+
* keywords, an array's items, and rules of its own.
|
|
759
|
+
*/
|
|
760
|
+
type Condition = ValueKeywords &
|
|
761
|
+
ObjectRules & {
|
|
762
|
+
type?: RuleType | RuleType[];
|
|
763
|
+
enum?: Array<string | number | boolean | null>;
|
|
764
|
+
required?: string[];
|
|
765
|
+
properties?: Record<string, Condition>;
|
|
766
|
+
items?: Condition;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const conditionSchema: z.ZodType<Condition, Condition> = z
|
|
770
|
+
.lazy(() =>
|
|
771
|
+
z.strictObject({
|
|
772
|
+
type: z.union([ruleTypeSchema, z.array(ruleTypeSchema).min(1)]).optional(),
|
|
773
|
+
enum: z
|
|
774
|
+
.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
|
775
|
+
.min(1)
|
|
776
|
+
.optional(),
|
|
777
|
+
...valueKeywordsSchema.shape,
|
|
778
|
+
required: z.array(z.string()).optional(),
|
|
779
|
+
properties: z.record(z.string(), conditionSchema).optional(),
|
|
780
|
+
items: conditionSchema.optional(),
|
|
781
|
+
...objectRulesSchema.shape,
|
|
782
|
+
}),
|
|
783
|
+
)
|
|
784
|
+
.meta({ id: "SchemaCondition" });
|
|
785
|
+
|
|
786
|
+
/** The members of a rule's `anyOf`, `oneOf` or `allOf`. */
|
|
787
|
+
const conditionsSchema = z.array(conditionSchema).min(1).meta({ id: "SchemaConditions" });
|
|
788
|
+
|
|
789
|
+
export const objectRulesSchema = z.strictObject({
|
|
790
|
+
anyOf: conditionsSchema.optional(),
|
|
791
|
+
oneOf: conditionsSchema.optional(),
|
|
792
|
+
allOf: conditionsSchema.optional(),
|
|
793
|
+
not: conditionSchema.optional(),
|
|
794
|
+
if: conditionSchema.optional(),
|
|
795
|
+
then: conditionSchema.optional(),
|
|
796
|
+
else: conditionSchema.optional(),
|
|
797
|
+
dependentRequired: z.record(z.string(), z.array(z.string())).optional(),
|
|
798
|
+
dependentSchemas: z.record(z.string(), conditionSchema).optional(),
|
|
799
|
+
minProperties: countSchema,
|
|
800
|
+
maxProperties: countSchema,
|
|
801
|
+
}) satisfies z.ZodType<ObjectRules>;
|
|
802
|
+
|
|
803
|
+
/** The keywords that check an object's properties against each other. */
|
|
804
|
+
export const OBJECT_RULE_KEYWORDS = objectRulesSchema.keyof().options;
|
|
805
|
+
|
|
806
|
+
/** A rule keyword's array value, or none when it holds no array. */
|
|
807
|
+
function listed(value: unknown) {
|
|
808
|
+
return Array.isArray(value) ? (value as unknown[]) : [];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** A rule keyword's map value, or an empty one when it holds no map. */
|
|
812
|
+
function keyed(value: unknown) {
|
|
813
|
+
return isRecord(value) ? value : {};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* A rule and every rule nested in it that checks the same object, through
|
|
818
|
+
* its combinators, `not`, `if`/`then`/`else` and `dependentSchemas`, but
|
|
819
|
+
* not inside a property's own value.
|
|
820
|
+
*/
|
|
821
|
+
export function ruleNodes(rule: unknown): Array<Record<string, unknown>> {
|
|
822
|
+
if (!isRecord(rule)) return [];
|
|
823
|
+
return [
|
|
824
|
+
rule,
|
|
825
|
+
...[
|
|
826
|
+
...["anyOf", "oneOf", "allOf"].flatMap((keyword) => listed(rule[keyword])),
|
|
827
|
+
rule.not,
|
|
828
|
+
rule.if,
|
|
829
|
+
rule.then,
|
|
830
|
+
rule.else,
|
|
831
|
+
...Object.values(keyed(rule.dependentSchemas)),
|
|
832
|
+
].flatMap(ruleNodes),
|
|
833
|
+
];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The property names a rule reaches on the object it checks: those any of
|
|
838
|
+
* its nodes (`ruleNodes`) requires, reads or makes depend on each other.
|
|
839
|
+
*/
|
|
840
|
+
export function ruleNames(rule: unknown) {
|
|
841
|
+
return ruleNodes(rule)
|
|
842
|
+
.flatMap((node) => [
|
|
843
|
+
...listed(node.required),
|
|
844
|
+
...Object.keys(keyed(node.properties)),
|
|
845
|
+
...Object.entries(keyed(node.dependentRequired)).flatMap(([name, names]) => [
|
|
846
|
+
name,
|
|
847
|
+
...listed(names),
|
|
848
|
+
]),
|
|
849
|
+
...Object.keys(keyed(node.dependentSchemas)),
|
|
850
|
+
])
|
|
851
|
+
.filter((name): name is string => typeof name === "string");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** What an array holds: a scalar (a string's values listed in `enum`, as a property's), or an object with properties of its own; with `"null"` beside its type when an item may be null. */
|
|
855
|
+
type SchemaItems = ValueKeywords &
|
|
856
|
+
ObjectRules & {
|
|
857
|
+
type: ItemType | [ItemType, "null"];
|
|
858
|
+
enum?: Array<string | null>;
|
|
859
|
+
properties?: Record<string, SchemaProperty>;
|
|
860
|
+
required?: string[];
|
|
861
|
+
"x-order"?: string[];
|
|
862
|
+
};
|
|
689
863
|
|
|
690
864
|
/**
|
|
691
865
|
* A JSON Schema property, in the part of JSON Schema a field editor holds:
|
|
692
866
|
* a type (with `"null"` beside it when the value may be null), an `enum` of
|
|
693
867
|
* strings for a string, an object's `properties`, `required` and the
|
|
694
|
-
* `x-order` they are shown in, an array's `items`, a `description`,
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
* a tool's input only)
|
|
868
|
+
* `x-order` they are shown in, an array's `items`, a `description`, the
|
|
869
|
+
* value keywords its type takes (`VALUE_KEYWORD_TYPES`), an object's rules,
|
|
870
|
+
* and the extensions `x-sensitive` (redacted from logs), `x-source`
|
|
871
|
+
* (top-level scalar fields of a tool's input only) and `x-target` (top-level
|
|
872
|
+
* fields of a tool's input only).
|
|
698
873
|
*/
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
874
|
+
type SchemaProperty = ValueKeywords &
|
|
875
|
+
ObjectRules & {
|
|
876
|
+
type: SchemaType | [SchemaType, "null"];
|
|
877
|
+
description?: string;
|
|
878
|
+
/** A nullable enum lists `null` beside its values, as the console stores one. */
|
|
879
|
+
enum?: Array<string | null>;
|
|
880
|
+
properties?: Record<string, SchemaProperty>;
|
|
881
|
+
required?: string[];
|
|
882
|
+
"x-order"?: string[];
|
|
883
|
+
items?: SchemaItems;
|
|
884
|
+
"x-sensitive"?: boolean;
|
|
885
|
+
"x-source"?: FieldSource;
|
|
886
|
+
"x-target"?: FieldTarget;
|
|
887
|
+
};
|
|
888
|
+
type SchemaType = z.infer<typeof schemaTypeSchema>;
|
|
889
|
+
const itemTypeSchema = z.enum([...SCALAR_TYPES, "object"]);
|
|
890
|
+
type ItemType = z.infer<typeof itemTypeSchema>;
|
|
891
|
+
|
|
892
|
+
/** A nullable enum lists `null` beside its values, as the console stores one. */
|
|
893
|
+
const enumSchema = z
|
|
894
|
+
.array(z.union([z.string().trim().min(1), z.null()]))
|
|
895
|
+
.min(1)
|
|
896
|
+
.max(FIELD_LIMITS.enumValues + 1)
|
|
897
|
+
.optional();
|
|
898
|
+
|
|
899
|
+
const itemsSchema: z.ZodType<SchemaItems, SchemaItems> = z.lazy(() =>
|
|
900
|
+
z.strictObject({
|
|
901
|
+
type: z.union([itemTypeSchema, z.tuple([itemTypeSchema, z.literal("null")])]),
|
|
902
|
+
enum: enumSchema,
|
|
903
|
+
properties: z.record(propertyNameSchema, schemaPropertySchema).optional(),
|
|
904
|
+
required: z.array(z.string()).optional(),
|
|
905
|
+
"x-order": propertyOrderSchema,
|
|
906
|
+
...valueKeywordsSchema.shape,
|
|
907
|
+
...objectRulesSchema.shape,
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const schemaPropertySchema: z.ZodType<SchemaProperty, SchemaProperty> = z.lazy(() =>
|
|
912
|
+
z.strictObject({
|
|
913
|
+
type: z.union([schemaTypeSchema, z.tuple([schemaTypeSchema, z.literal("null")])]),
|
|
914
|
+
description: z.string().trim().max(FIELD_LIMITS.descriptionLength).optional(),
|
|
915
|
+
enum: enumSchema,
|
|
916
|
+
properties: z.record(propertyNameSchema, schemaPropertySchema).optional(),
|
|
917
|
+
required: z.array(z.string()).optional(),
|
|
918
|
+
"x-order": propertyOrderSchema,
|
|
919
|
+
items: itemsSchema.optional(),
|
|
920
|
+
"x-sensitive": z.boolean().optional(),
|
|
921
|
+
"x-source": fieldSourceSchema.optional(),
|
|
922
|
+
"x-target": fieldTargetSchema.optional(),
|
|
923
|
+
...valueKeywordsSchema.shape,
|
|
924
|
+
...objectRulesSchema.shape,
|
|
925
|
+
}),
|
|
926
|
+
);
|
|
721
927
|
|
|
722
928
|
/** A node's own type, without the `"null"` beside it. */
|
|
723
929
|
function typeOf(node: { type: SchemaProperty["type"] }) {
|
|
@@ -728,29 +934,45 @@ function typeOf(node: { type: SchemaProperty["type"] }) {
|
|
|
728
934
|
type RowIssue = { at: Array<string | number>; message: string };
|
|
729
935
|
|
|
730
936
|
/** A node holding properties: a property, an array's items, or an input or output schema. */
|
|
731
|
-
type ObjectNode =
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
937
|
+
type ObjectNode = ObjectRules &
|
|
938
|
+
ValueKeywords & {
|
|
939
|
+
type: SchemaProperty["type"];
|
|
940
|
+
properties?: Record<string, SchemaProperty>;
|
|
941
|
+
required?: string[];
|
|
942
|
+
"x-order"?: string[];
|
|
943
|
+
};
|
|
737
944
|
|
|
738
|
-
/**
|
|
945
|
+
/**
|
|
946
|
+
* What a node's own keywords break: `properties`, `required`, `x-order` or
|
|
947
|
+
* a rule off an object, the next two or a rule naming no property, and a
|
|
948
|
+
* value keyword off the types it bounds.
|
|
949
|
+
*/
|
|
739
950
|
function nodeIssues(node: ObjectNode) {
|
|
740
951
|
const issues = new Array<RowIssue>();
|
|
741
|
-
const
|
|
742
|
-
|
|
952
|
+
const type = typeOf(node);
|
|
953
|
+
const object = type === "object";
|
|
954
|
+
for (const keyword of ["properties", "required", "x-order", ...OBJECT_RULE_KEYWORDS] as const) {
|
|
743
955
|
if (!object && node[keyword] !== undefined) {
|
|
744
956
|
issues.push({ at: [keyword], message: "only an object has it" });
|
|
745
957
|
}
|
|
746
958
|
}
|
|
959
|
+
const named = (name: string) => Object.hasOwn(node.properties ?? {}, name);
|
|
747
960
|
for (const keyword of ["required", "x-order"] as const) {
|
|
748
961
|
node[keyword]?.forEach((name, index) => {
|
|
749
|
-
if (!
|
|
750
|
-
issues.push({ at: [keyword, index], message: "names no property" });
|
|
751
|
-
}
|
|
962
|
+
if (!named(name)) issues.push({ at: [keyword, index], message: "names no property" });
|
|
752
963
|
});
|
|
753
964
|
}
|
|
965
|
+
for (const keyword of OBJECT_RULE_KEYWORDS) {
|
|
966
|
+
if (object && !ruleNames({ [keyword]: node[keyword] }).every(named)) {
|
|
967
|
+
issues.push({ at: [keyword], message: "names no property" });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
for (const [keyword, types] of Object.entries(VALUE_KEYWORD_TYPES)) {
|
|
971
|
+
if (node[keyword as keyof ValueKeywords] === undefined) continue;
|
|
972
|
+
if (!types.some((bounded) => bounded === type)) {
|
|
973
|
+
issues.push({ at: [keyword], message: `only a ${types.join(" or ")} has it` });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
754
976
|
return issues;
|
|
755
977
|
}
|
|
756
978
|
|
|
@@ -759,7 +981,7 @@ function nodeIssues(node: ObjectNode) {
|
|
|
759
981
|
* them, beside `null` exactly when the type allows null, as the console
|
|
760
982
|
* stores a nullable one.
|
|
761
983
|
*/
|
|
762
|
-
function enumIssueOf(property: SchemaProperty, type: string | undefined) {
|
|
984
|
+
function enumIssueOf(property: Pick<SchemaProperty, "enum" | "type">, type: string | undefined) {
|
|
763
985
|
if (property.enum === undefined) return undefined;
|
|
764
986
|
if (type !== "string") return "an enum lists a string's values";
|
|
765
987
|
const values = property.enum.filter((value) => value !== null);
|
|
@@ -854,7 +1076,10 @@ function heldByRows(
|
|
|
854
1076
|
const at = [...path, "properties", name];
|
|
855
1077
|
report(at, propertyIssues(property, bindings));
|
|
856
1078
|
heldByRows(property, context, at, undefined);
|
|
857
|
-
if (property.items)
|
|
1079
|
+
if (!property.items) continue;
|
|
1080
|
+
const enumIssue = enumIssueOf(property.items, typeOf(property.items));
|
|
1081
|
+
if (enumIssue) report([...at, "items"], [{ at: ["enum"], message: enumIssue }]);
|
|
1082
|
+
heldByRows(property.items, context, [...at, "items"], undefined);
|
|
858
1083
|
}
|
|
859
1084
|
}
|
|
860
1085
|
|
|
@@ -862,7 +1087,8 @@ function heldByRows(
|
|
|
862
1087
|
* An object's JSON Schema, the shape of a tool's or trigger's input and
|
|
863
1088
|
* output, and of what the console's field rows write. `bindings`, given for
|
|
864
1089
|
* a tool's input, are the extensions its top-level fields may carry:
|
|
865
|
-
* `x-source` on a scalar, `x-target` on any field.
|
|
1090
|
+
* `x-source` on a scalar, `x-target` on any field. Only a tool's input holds
|
|
1091
|
+
* rules at its root, as the console keeps them beside its rows.
|
|
866
1092
|
*/
|
|
867
1093
|
export function objectJsonSchemaOf(bindings?: ReadonlyArray<Binding>) {
|
|
868
1094
|
return z
|
|
@@ -871,8 +1097,20 @@ export function objectJsonSchemaOf(bindings?: ReadonlyArray<Binding>) {
|
|
|
871
1097
|
properties: z.record(propertyNameSchema, schemaPropertySchema).optional(),
|
|
872
1098
|
required: z.array(z.string()).optional(),
|
|
873
1099
|
"x-order": propertyOrderSchema,
|
|
1100
|
+
...objectRulesSchema.shape,
|
|
874
1101
|
})
|
|
875
|
-
.superRefine((schema, context) =>
|
|
1102
|
+
.superRefine((schema, context) => {
|
|
1103
|
+
heldByRows(schema, context, [], bindings);
|
|
1104
|
+
if (bindings !== undefined) return;
|
|
1105
|
+
for (const keyword of OBJECT_RULE_KEYWORDS) {
|
|
1106
|
+
if (schema[keyword] === undefined) continue;
|
|
1107
|
+
context.addIssue({
|
|
1108
|
+
code: "custom",
|
|
1109
|
+
path: [keyword],
|
|
1110
|
+
message: "only a tool's input has rules at its root",
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
876
1114
|
}
|
|
877
1115
|
const objectSchema = objectJsonSchemaOf();
|
|
878
1116
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// cspell:ignore fefe fff
|
|
2
|
+
import { RE2JS } from "re2js";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* A tool input schema's `pattern`: a regular expression JavaScript reads with
|
|
6
|
+
* no flags, which Cortex matches on RE2 so a value is checked in time linear
|
|
7
|
+
* in its length. A pattern RE2 cannot match as JavaScript reads it is refused
|
|
8
|
+
* by Cortex and left out of what the TypeScript SDK exports.
|
|
9
|
+
*
|
|
10
|
+
* RE2 matches code points where JavaScript matches UTF-16 units, so the
|
|
11
|
+
* pattern and every input lift each surrogate unit into a private-use plane,
|
|
12
|
+
* where it is one code point: RE2 then reads the units JavaScript reads.
|
|
13
|
+
* Nothing collides there, since a real character in that plane is astral,
|
|
14
|
+
* arrives as two surrogate units and is lifted in turn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What one escape or character in a class stands for, spelled as the inside
|
|
19
|
+
* of an RE2 class: a single character carries its UTF-16 unit, and an
|
|
20
|
+
* unescaped `-` is marked as one that may make a range.
|
|
21
|
+
*/
|
|
22
|
+
type Atom = { body: string; char?: number; dash?: boolean };
|
|
23
|
+
|
|
24
|
+
/** JavaScript's `\s`, its WhiteSpace and LineTerminator characters, as the inside of an RE2 class. */
|
|
25
|
+
const SPACE =
|
|
26
|
+
"\\x{9}-\\x{d}\\x{20}\\x{a0}\\x{1680}\\x{2000}-\\x{200a}\\x{2028}\\x{2029}\\x{202f}\\x{205f}\\x{3000}\\x{feff}";
|
|
27
|
+
/** Every character `SPACE` leaves out: JavaScript's `\S`. */
|
|
28
|
+
const NON_SPACE =
|
|
29
|
+
"\\x{0}-\\x{8}\\x{e}-\\x{1f}\\x{21}-\\x{9f}\\x{a1}-\\x{167f}\\x{1681}-\\x{1fff}\\x{200b}-\\x{2027}" +
|
|
30
|
+
"\\x{202a}-\\x{202e}\\x{2030}-\\x{205e}\\x{2060}-\\x{2fff}\\x{3001}-\\x{fefe}\\x{ff00}-\\x{10ffff}";
|
|
31
|
+
/** The set escapes as JavaScript reads them; RE2's own `\d` and `\w` match, its `\s` holds only ASCII. */
|
|
32
|
+
const SET_ESCAPES = new Map([
|
|
33
|
+
["d", "\\d"],
|
|
34
|
+
["D", "\\D"],
|
|
35
|
+
["w", "\\w"],
|
|
36
|
+
["W", "\\W"],
|
|
37
|
+
["s", SPACE],
|
|
38
|
+
["S", NON_SPACE],
|
|
39
|
+
]);
|
|
40
|
+
/** JavaScript's `.`: every character but a line terminator, where RE2's leaves out only `\n`. */
|
|
41
|
+
const DOT = "[^\\x{a}\\x{d}\\x{2028}\\x{2029}]";
|
|
42
|
+
const CONTROL_ESCAPES = new Map([
|
|
43
|
+
["f", 0x0c],
|
|
44
|
+
["n", 0x0a],
|
|
45
|
+
["r", 0x0d],
|
|
46
|
+
["t", 0x09],
|
|
47
|
+
["v", 0x0b],
|
|
48
|
+
]);
|
|
49
|
+
const QUANTIFIER = /\{\d+(?:,\d*)?\}/y;
|
|
50
|
+
const SURROGATE = /[\uD800-\uDFFF]/g;
|
|
51
|
+
/** Where surrogate unit `0xd800 + n` is lifted: code point `0xf0000 + n`. */
|
|
52
|
+
const LIFTED_SURROGATES = 0xf0000;
|
|
53
|
+
const HEX = /[0-9A-Fa-f]+/y;
|
|
54
|
+
/** What JavaScript's `[^]` and `[]` hold: every character, and none. */
|
|
55
|
+
const ANY = "[\\x{0}-\\x{10ffff}]";
|
|
56
|
+
const NONE = "[^\\x{0}-\\x{10ffff}]";
|
|
57
|
+
|
|
58
|
+
/** A thrown error: the pattern holds what RE2 cannot match as JavaScript reads it. */
|
|
59
|
+
function unsupported(what: string): never {
|
|
60
|
+
throw new Error(`a pattern with ${what}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A UTF-16 unit as the one code point RE2 reads: a surrogate lifted, any other unit itself. */
|
|
64
|
+
function lifted(unit: number) {
|
|
65
|
+
return unit >= 0xd800 && unit <= 0xdfff ? LIFTED_SURROGATES + unit - 0xd800 : unit;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `input` with every surrogate unit lifted, one code point per UTF-16 unit. */
|
|
69
|
+
function liftedText(input: string) {
|
|
70
|
+
return input.replace(SURROGATE, (unit) => String.fromCodePoint(lifted(unit.charCodeAt(0))));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One UTF-16 unit as RE2 reads it literally, inside a class or out. */
|
|
74
|
+
function literal(unit: number) {
|
|
75
|
+
const char = lifted(unit);
|
|
76
|
+
const spelled = String.fromCodePoint(char);
|
|
77
|
+
return /^[A-Za-z0-9]$/.test(spelled) ? spelled : `\\x{${char.toString(16)}}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The units from `from` to `to` as the inside of an RE2 class: the part
|
|
82
|
+
* among the surrogates, lifted, apart from the parts below and above it.
|
|
83
|
+
*/
|
|
84
|
+
function unitRange(from: number, to: number) {
|
|
85
|
+
return (
|
|
86
|
+
[
|
|
87
|
+
[from, Math.min(to, 0xd7ff)],
|
|
88
|
+
[Math.max(from, 0xd800), Math.min(to, 0xdfff)],
|
|
89
|
+
[Math.max(from, 0xe000), to],
|
|
90
|
+
] satisfies Array<[number, number]>
|
|
91
|
+
)
|
|
92
|
+
.filter(([low, high]) => low <= high)
|
|
93
|
+
.map(([low, high]) => `${literal(low)}-${literal(high)}`)
|
|
94
|
+
.join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function character(char: number, dash = false) {
|
|
98
|
+
return { body: literal(char), char, dash };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A class's atoms as the inside of an RE2 class: an unescaped dash between
|
|
103
|
+
* two characters makes a range, and beside a set escape it is a character,
|
|
104
|
+
* as JavaScript reads them.
|
|
105
|
+
*/
|
|
106
|
+
function classBody(atoms: Atom[]) {
|
|
107
|
+
let body = "";
|
|
108
|
+
for (let index = 0; index < atoms.length; index += 1) {
|
|
109
|
+
const [from, dash, to] = atoms.slice(index, index + 3);
|
|
110
|
+
if (!from) break;
|
|
111
|
+
if (!dash?.dash || !to) {
|
|
112
|
+
body += from.body;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
body +=
|
|
116
|
+
from.char !== undefined && to.char !== undefined
|
|
117
|
+
? unitRange(from.char, to.char)
|
|
118
|
+
: `${from.body}${dash.body}${to.body}`;
|
|
119
|
+
index += 2;
|
|
120
|
+
}
|
|
121
|
+
return body;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A pattern read left to right as JavaScript reads it with no flags (Annex B
|
|
126
|
+
* included), written in RE2's syntax; throws on a lookaround, a
|
|
127
|
+
* backreference, a legacy octal escape, a group modifier or a `\c` without
|
|
128
|
+
* its letter. It reads UTF-16 units, as JavaScript does. Groups do not
|
|
129
|
+
* capture: nothing reads them.
|
|
130
|
+
*/
|
|
131
|
+
class Translation {
|
|
132
|
+
private at = 0;
|
|
133
|
+
|
|
134
|
+
public constructor(private readonly pattern: string) {}
|
|
135
|
+
|
|
136
|
+
public spell() {
|
|
137
|
+
let re2 = "";
|
|
138
|
+
while (this.at < this.pattern.length) re2 += this.term();
|
|
139
|
+
return re2;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private term() {
|
|
143
|
+
const char = this.pattern[this.at] ?? "";
|
|
144
|
+
if (char === "\\") return this.escapeOutsideClass();
|
|
145
|
+
if (char === "[") return this.characterClass();
|
|
146
|
+
if (char === "(") return this.group();
|
|
147
|
+
if (char === "{") return this.brace();
|
|
148
|
+
if (!"|)^$*+?.".includes(char)) return literal(this.unit());
|
|
149
|
+
this.at += 1;
|
|
150
|
+
return char === "." ? DOT : char;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private escapeOutsideClass() {
|
|
154
|
+
this.at += 1;
|
|
155
|
+
const assertion = this.pattern[this.at];
|
|
156
|
+
if (assertion === "b" || assertion === "B") {
|
|
157
|
+
this.at += 1;
|
|
158
|
+
return `\\${assertion}`;
|
|
159
|
+
}
|
|
160
|
+
const atom = this.escape(false);
|
|
161
|
+
return "char" in atom ? atom.body : `[${atom.body}]`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The escape after a backslash, which `at` is past; in a class `\b` is a backspace. */
|
|
165
|
+
private escape(insideClass: boolean) {
|
|
166
|
+
const next = this.pattern[this.at] ?? unsupported("a trailing backslash");
|
|
167
|
+
this.at += 1;
|
|
168
|
+
const set = SET_ESCAPES.get(next);
|
|
169
|
+
if (set) return { body: set };
|
|
170
|
+
const control = insideClass && next === "b" ? 0x08 : CONTROL_ESCAPES.get(next);
|
|
171
|
+
if (control !== undefined) return character(control);
|
|
172
|
+
if (next === "c") return character(this.controlLetter(insideClass));
|
|
173
|
+
if (next === "x" || next === "u") {
|
|
174
|
+
return character(this.hex(next === "x" ? 2 : 4) ?? next.charCodeAt(0));
|
|
175
|
+
}
|
|
176
|
+
if (next === "0" && !/^\d$/.test(this.pattern[this.at] ?? "")) return character(0);
|
|
177
|
+
if (/^[\dk]$/.test(next)) unsupported("a backreference or an octal escape");
|
|
178
|
+
this.at -= 1;
|
|
179
|
+
return character(this.unit());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The control character a `\c` names: a letter, or in a class also a digit or `_`. */
|
|
183
|
+
private controlLetter(insideClass: boolean) {
|
|
184
|
+
const letter = this.pattern[this.at] ?? "";
|
|
185
|
+
if (!(insideClass ? /^\w$/ : /^[A-Za-z]$/).test(letter)) {
|
|
186
|
+
unsupported("a \\c without its letter");
|
|
187
|
+
}
|
|
188
|
+
this.at += 1;
|
|
189
|
+
return letter.charCodeAt(0) % 32;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `length` hex digits at `at` as a UTF-16 unit, consumed; undefined when fewer follow. */
|
|
193
|
+
private hex(length: number) {
|
|
194
|
+
HEX.lastIndex = this.at;
|
|
195
|
+
const digits = HEX.exec(this.pattern)?.[0] ?? "";
|
|
196
|
+
if (digits.length < length) return undefined;
|
|
197
|
+
this.at += length;
|
|
198
|
+
return Number.parseInt(digits.slice(0, length), 16);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private unit() {
|
|
202
|
+
const unit = this.pattern.charCodeAt(this.at);
|
|
203
|
+
if (Number.isNaN(unit)) unsupported("a pattern cut short");
|
|
204
|
+
this.at += 1;
|
|
205
|
+
return unit;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A class, `at` on its `[`. In JavaScript a `]` always closes one, so
|
|
210
|
+
* `[]` holds nothing and `[^]` everything.
|
|
211
|
+
*/
|
|
212
|
+
private characterClass() {
|
|
213
|
+
this.at += 1;
|
|
214
|
+
const negated = this.pattern[this.at] === "^";
|
|
215
|
+
if (negated) this.at += 1;
|
|
216
|
+
const atoms: Atom[] = [];
|
|
217
|
+
while (this.pattern[this.at] !== "]") {
|
|
218
|
+
if (this.at >= this.pattern.length) unsupported("an unclosed class");
|
|
219
|
+
atoms.push(this.classAtom());
|
|
220
|
+
}
|
|
221
|
+
this.at += 1;
|
|
222
|
+
const body = classBody(atoms);
|
|
223
|
+
if (!body) return negated ? ANY : NONE;
|
|
224
|
+
return `[${negated ? "^" : ""}${body}]`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private classAtom() {
|
|
228
|
+
const char = this.pattern[this.at];
|
|
229
|
+
this.at += 1;
|
|
230
|
+
if (char === "\\") return this.escape(true);
|
|
231
|
+
if (char === "-") return character(0x2d, true);
|
|
232
|
+
this.at -= 1;
|
|
233
|
+
return character(this.unit());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A group's opening as RE2 reads it: every group, named or not, without a capture. */
|
|
237
|
+
private group() {
|
|
238
|
+
const opening = this.pattern.slice(this.at, this.at + 4);
|
|
239
|
+
if (/^\(\?<[^=!]/.test(opening)) {
|
|
240
|
+
const name = this.pattern.indexOf(">", this.at);
|
|
241
|
+
if (name < 0) unsupported("an unclosed group name");
|
|
242
|
+
this.at = name + 1;
|
|
243
|
+
} else if (opening.startsWith("(?")) {
|
|
244
|
+
if (!opening.startsWith("(?:")) unsupported("a lookaround or a group modifier");
|
|
245
|
+
this.at += 3;
|
|
246
|
+
} else {
|
|
247
|
+
this.at += 1;
|
|
248
|
+
}
|
|
249
|
+
return "(?:";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A quantifier, or a brace JavaScript reads as a character because it opens none. */
|
|
253
|
+
private brace() {
|
|
254
|
+
QUANTIFIER.lastIndex = this.at;
|
|
255
|
+
const quantifier = QUANTIFIER.exec(this.pattern)?.[0];
|
|
256
|
+
this.at += quantifier?.length ?? 1;
|
|
257
|
+
return quantifier ?? literal(0x7b);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A matcher for a pattern JavaScript compiles, meaning what the pattern
|
|
263
|
+
* means to JavaScript with no flags, UTF-16 unit for unit; undefined when
|
|
264
|
+
* RE2 cannot match it so, as for a lookaround, a backreference or a
|
|
265
|
+
* repetition past 1000. Its `test` runs on RE2, in time linear in the input.
|
|
266
|
+
* It never calls `new RegExp`, so it works while a caller has replaced that
|
|
267
|
+
* global.
|
|
268
|
+
*/
|
|
269
|
+
export function linearPattern(pattern: string) {
|
|
270
|
+
try {
|
|
271
|
+
const re2 = RE2JS.compile(new Translation(pattern).spell());
|
|
272
|
+
return { test: (input: string) => re2.test(liftedText(input)) };
|
|
273
|
+
} catch {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|