Skip to content

Commit e6d8a91

Browse files
authored
feat: support permission type annotations (use typechecking) (#21)
Parse the optional return-type annotation on permissions (`permission view: user | group = ...`) introduced by the SpiceDB `use typechecking` flag. Previously any annotated permission failed to parse, which — in editor tooling — disabled go-to-definition and semantic highlighting for the entire schema. - dsl.ts: add an optional `: type | type` annotation to the permission grammar, restricted to plain type identifiers to match the SpiceDB parser. Expose it as `ParsedPermission.annotatedTypes` (a TypeExpr of TypeRefs) and walk it in findReferenceNode and mapParseNodes so go-to-definition resolves on annotation types. - resolution.ts: include annotation type refs in resolvedReferences() so they resolve and highlight like relation types. - Tests for the AST, the error cases mirrored from the SpiceDB parser fixtures, findReferenceNode, and the resolver.
1 parent 911ea75 commit e6d8a91

4 files changed

Lines changed: 252 additions & 11 deletions

File tree

src/dsl.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { parseSchema, stringLiteral } from "./dsl";
2+
import { findReferenceNode, parse, parseSchema, stringLiteral } from "./dsl";
33

44
// Takes an expression as an argument and throws if that assertion fails.
55
// This primarily exists to provide typescript narrowing in a statement,
@@ -495,6 +495,113 @@ definition user {}
495495
});
496496
});
497497

498+
describe("permission type annotations", () => {
499+
it("parses a permission with a single type annotation", () => {
500+
const schema = `definition foo {
501+
permission view: user = viewer
502+
}`;
503+
const parsed = parseSchema(schema);
504+
505+
const definition = parsed?.definitions[0];
506+
assert(definition);
507+
assert(definition.kind === "objectDef");
508+
509+
const permission = definition.permissions[0];
510+
assert(permission);
511+
expect(permission.name).toEqual("view");
512+
513+
// The compute expression is still parsed normally.
514+
assert(permission.expr.kind === "relationref");
515+
expect(permission.expr.relationName).toEqual("viewer");
516+
517+
// The annotation is captured as a type expression.
518+
assert(permission.annotatedTypes);
519+
expect(permission.annotatedTypes.types.map((t) => t.path)).toEqual([
520+
"user",
521+
]);
522+
const annoType = permission.annotatedTypes.types[0];
523+
assert(annoType);
524+
expect(annoType.kind).toEqual("typeref");
525+
expect(annoType.relationName).toBeUndefined();
526+
expect(annoType.wildcard).toEqual(false);
527+
});
528+
529+
it("parses a permission with multiple piped type annotations", () => {
530+
const schema = `definition foo {
531+
permission view: user | group | team = viewer
532+
}`;
533+
const parsed = parseSchema(schema);
534+
535+
const definition = parsed?.definitions[0];
536+
assert(definition);
537+
assert(definition.kind === "objectDef");
538+
539+
const permission = definition.permissions[0];
540+
assert(permission);
541+
assert(permission.annotatedTypes);
542+
expect(permission.annotatedTypes.types.map((t) => t.path)).toEqual([
543+
"user",
544+
"group",
545+
"team",
546+
]);
547+
});
548+
549+
it("leaves annotatedTypes undefined when there is no annotation", () => {
550+
const schema = `definition foo {
551+
permission view = viewer
552+
}`;
553+
const parsed = parseSchema(schema);
554+
555+
const definition = parsed?.definitions[0];
556+
assert(definition);
557+
assert(definition.kind === "objectDef");
558+
559+
const permission = definition.permissions[0];
560+
assert(permission);
561+
expect(permission.annotatedTypes).toBeUndefined();
562+
});
563+
564+
it("rejects a double colon", () => {
565+
const schema = `definition foo {
566+
permission view:: user = viewer
567+
}`;
568+
expect(parseSchema(schema)).toBeUndefined();
569+
});
570+
571+
it("rejects a pipe with no preceding type", () => {
572+
const schema = `definition foo {
573+
permission view: | user = viewer
574+
}`;
575+
expect(parseSchema(schema)).toBeUndefined();
576+
});
577+
578+
it("rejects a trailing pipe with no following type", () => {
579+
const schema = `definition foo {
580+
permission view: user | = viewer
581+
}`;
582+
expect(parseSchema(schema)).toBeUndefined();
583+
});
584+
585+
it("finds the annotation type as a reference node for go-to-definition", () => {
586+
const schema = `definition user {}
587+
definition foo {
588+
permission view: user = viewer
589+
}`;
590+
const result = parse(schema);
591+
assert(!result.error);
592+
assert(result.schema);
593+
594+
// "\tpermission view: user = viewer" is line 3 (1-indexed).
595+
const line = "\tpermission view: user = viewer";
596+
const col = line.indexOf("user") + 2; // 1-indexed, inside "user"
597+
const found = findReferenceNode(result.schema, 3, col);
598+
assert(found);
599+
assert(found.node);
600+
assert(found.node.kind === "typeref");
601+
expect(found.node.path).toEqual("user");
602+
});
603+
});
604+
498605
describe("partial syntax", () => {
499606
it("parses a basic partial", () => {
500607
const schema = `partial thing {

src/dsl.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,12 @@ export type ParsedBinaryExpression = {
320320
export type ParsedPermission = {
321321
kind: "permission";
322322
name: string;
323+
/**
324+
* annotatedTypes is the optional `use typechecking` return-type annotation on the
325+
* permission (e.g. `permission view: user | group = ...`), or undefined if none was
326+
* written. Each annotated type is a plain type reference to a definition.
327+
*/
328+
annotatedTypes: TypeExpr | undefined;
323329
expr: ParsedExpression;
324330
range: TextRange;
325331
};
@@ -636,20 +642,59 @@ const tableParser: Parser<ParsedExpression> = table.reduce(
636642

637643
const expr = tableParser.trim(whitespace);
638644

645+
// Permission type annotation (the `use typechecking` return-type annotation).
646+
// Unlike a relation's allowed types, an annotation is a pipe-separated list of plain
647+
// type identifiers -- no wildcards, subrelations, caveats, or expiration -- matching
648+
// the SpiceDB grammar `permission foo: user | group = ...`.
649+
const annotationTypeRef: Parser<TypeRef> = seqMap(
650+
index,
651+
identifier,
652+
index,
653+
function (startIndex, name, endIndex) {
654+
return {
655+
kind: "typeref",
656+
path: name,
657+
relationName: undefined,
658+
wildcard: false,
659+
withCaveat: undefined,
660+
withExpiration: undefined,
661+
range: { startIndex: startIndex, endIndex: endIndex },
662+
};
663+
},
664+
);
665+
666+
const pipedAnnotationTypeRef = pipe.then(annotationTypeRef);
667+
668+
const permissionTypeAnnotation: Parser<TypeExpr> = seqMap(
669+
index,
670+
seq(annotationTypeRef, pipedAnnotationTypeRef.atLeast(0)),
671+
index,
672+
function (startIndex, data, endIndex) {
673+
const remaining = data[1];
674+
return {
675+
kind: "typeexpr",
676+
types: [data[0], ...remaining],
677+
range: { startIndex: startIndex, endIndex: endIndex },
678+
};
679+
},
680+
);
681+
639682
// Definitions members.
640683
const permission: Parser<ParsedPermission> = seqMap(
641684
index,
642685
seq(
643686
lexeme(string("permission")),
644687
identifier,
688+
colon.then(permissionTypeAnnotation).atMost(1),
645689
equal.then(expr).skip(terminator.atMost(1)),
646690
),
647691
index,
648692
function (startIndex, data, endIndex) {
649693
return {
650694
kind: "permission",
651695
name: data[1],
652-
expr: data[2],
696+
annotatedTypes: data[2][0],
697+
expr: data[3],
653698
range: { startIndex: startIndex, endIndex: endIndex },
654699
};
655700
},
@@ -918,6 +963,17 @@ function findReferenceNodeInPermission(
918963
lineNumber: number,
919964
columnPosition: number,
920965
): ParsedRelationRefExpression | TypeRef | undefined {
966+
// A `use typechecking` annotation type (e.g. the `user` in `permission view: user = ...`)
967+
// is a type reference, so resolve it like a relation's allowed type for go-to-definition.
968+
if (permission.annotatedTypes) {
969+
const annotatedType = permission.annotatedTypes.types.find(
970+
(typeRef: TypeRef) => rangeContains(typeRef, lineNumber, columnPosition),
971+
);
972+
if (annotatedType) {
973+
return annotatedType;
974+
}
975+
}
976+
921977
const found = flatMapExpression(permission.expr, (expr: ParsedExpression) => {
922978
if (!rangeContains(expr, lineNumber, columnPosition)) {
923979
return undefined;
@@ -989,6 +1045,9 @@ const mapParseNodes =
9891045
break;
9901046

9911047
case "permission":
1048+
if (node.annotatedTypes) {
1049+
mapParseNodes(mapper)(node.annotatedTypes);
1050+
}
9921051
flatMapExpression(node.expr, mapper);
9931052
break;
9941053

src/resolution.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parse } from "./dsl";
3+
import { Resolver } from "./resolution";
4+
5+
// Takes an expression as an argument and throws if that assertion fails, providing
6+
// typescript narrowing in a statement.
7+
function assert(val: unknown, msg = "Assertion failed"): asserts val {
8+
if (!val) throw new Error(msg);
9+
}
10+
11+
describe("resolution", () => {
12+
it("resolves a permission type annotation as a type reference", () => {
13+
const schema = `definition user {}
14+
definition document {
15+
relation viewer: user
16+
permission view: user = viewer
17+
}`;
18+
const result = parse(schema);
19+
assert(!result.error);
20+
assert(result.schema);
21+
22+
const resolver = new Resolver(result.schema);
23+
const refs = resolver.resolvedReferences();
24+
25+
// Two type references: the relation's `user` and the annotation's `user`.
26+
const typeRefs = refs.filter((r) => r.kind === "type");
27+
expect(typeRefs).toHaveLength(2);
28+
for (const ref of typeRefs) {
29+
expect(ref.reference.path).toEqual("user");
30+
assert(ref.referencedTypeAndRelation);
31+
expect(ref.referencedTypeAndRelation.definition?.name).toEqual("user");
32+
}
33+
34+
// The compute expression reference `viewer` still resolves to the relation.
35+
const exprRefs = refs.filter((r) => r.kind === "expression");
36+
expect(exprRefs).toHaveLength(1);
37+
const exprRef = exprRefs[0];
38+
assert(exprRef);
39+
expect(exprRef.reference.relationName).toEqual("viewer");
40+
expect(exprRef.resolvedRelationOrPermission?.kind).toEqual("relation");
41+
});
42+
43+
it("marks an unknown annotation type as unresolved", () => {
44+
const schema = `definition document {
45+
permission view: nonexistent = editor
46+
}`;
47+
const result = parse(schema);
48+
assert(!result.error);
49+
assert(result.schema);
50+
51+
const resolver = new Resolver(result.schema);
52+
const typeRefs = resolver
53+
.resolvedReferences()
54+
.filter((r) => r.kind === "type");
55+
expect(typeRefs).toHaveLength(1);
56+
57+
const ref = typeRefs[0];
58+
assert(ref);
59+
expect(ref.reference.path).toEqual("nonexistent");
60+
expect(ref.referencedTypeAndRelation).toBeUndefined();
61+
});
62+
});

src/resolution.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -205,15 +205,28 @@ export class Resolver {
205205
return [];
206206
}
207207

208-
return def.relations.flatMap((rel: ParsedRelation) => {
209-
return rel.allowedTypes.types.map((typeRef: TypeRef) => {
210-
return {
211-
kind: "type",
212-
reference: typeRef,
213-
referencedTypeAndRelation: this.resolveTypeReference(typeRef),
214-
};
215-
});
216-
});
208+
const asTypeReference = (typeRef: TypeRef): ResolvedTypeReference => {
209+
return {
210+
kind: "type",
211+
reference: typeRef,
212+
referencedTypeAndRelation: this.resolveTypeReference(typeRef),
213+
};
214+
};
215+
216+
const relationRefs = def.relations.flatMap((rel: ParsedRelation) =>
217+
rel.allowedTypes.types.map(asTypeReference),
218+
);
219+
220+
// Permission `use typechecking` annotations reference types too, so resolve
221+
// them alongside relation types for highlighting and hover.
222+
const annotationRefs = def.permissions.flatMap(
223+
(perm: ParsedPermission) =>
224+
perm.annotatedTypes
225+
? perm.annotatedTypes.types.map(asTypeReference)
226+
: [],
227+
);
228+
229+
return [...relationRefs, ...annotationRefs];
217230
});
218231
}
219232

0 commit comments

Comments
 (0)