Skip to content

Commit cbe6ed3

Browse files
authored
Add type-level selector resolver for QueryDescendants (#1413)
1 parent 3df5f07 commit cbe6ed3

8 files changed

Lines changed: 452 additions & 1 deletion

File tree

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules
22
.github
33
include/generated
44
devhub-scraper-master
5+
tests

.github/workflows/test.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: Test
22

33
on:
44
pull_request:
5+
push:
6+
branches:
7+
- master
58

69
jobs:
710
test:
@@ -10,7 +13,7 @@ jobs:
1013
runs-on: ubuntu-latest
1114

1215
steps:
13-
- uses: actions/checkout@master
16+
- uses: actions/checkout@v4
1417

1518
- uses: bahmutov/npm-install@v1
1619

@@ -20,6 +23,9 @@ jobs:
2023
- name: Typecheck Files
2124
run: npm run check
2225

26+
- name: Run type-level tests
27+
run: npm test
28+
2329
- uses: actions/upload-artifact@v4
2430
with:
2531
name: include

include/customDefinitions.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,10 @@ interface Instance extends RBXObject {
583583
GetAttribute(this: Instance, attribute: string): AttributeValue | undefined;
584584
SetAttribute(this: Instance, attribute: string, value: AttributeValue | undefined): void;
585585
GetAttributes(this: Instance): Map<string, AttributeValue>;
586+
QueryDescendants<S extends string>(
587+
this: Instance,
588+
selector: Selector.ValidateSelector<S> extends S ? S : Selector.ValidateSelector<S>,
589+
): string extends S ? Array<Instance> : Array<Selector.Solve<S>>;
586590
readonly AncestryChanged: RBXScriptSignal<(child: Instance, parent: Instance | undefined) => void>;
587591
}
588592

include/roblox.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference no-default-lib="true"/>
22
/// <reference path="lua.d.ts" />
33
/// <reference path="macro_math.d.ts" />
4+
/// <reference path="selector.d.ts" />
45
/// <reference path="generated/enums.d.ts" />
56
/// <reference path="generated/None.d.ts" />
67
/// <reference types="@rbxts/compiler-types" />

include/selector.d.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/// <reference no-default-lib="true"/>
2+
3+
/**
4+
* Resolves the selector strings accepted by `Instance:QueryDescendants()` to the type of the
5+
* instances they match, so string-literal selectors get a precise return type. The grammar is
6+
* documented at https://create.roblox.com/docs/reference/engine/classes/Instance#QueryDescendants
7+
*
8+
* Only the class named in the last combinator segment affects the result, unioned across
9+
* comma-separated selectors. `.Tag` and `#Name` carry no class information and resolve to
10+
* `Instance`; attribute filters and pseudo-classes never change the class. Note that
11+
* whitespace is not a combinator in this grammar (descendant matching is `>>`), so spaces
12+
* are ignored rather than treated as separators.
13+
*/
14+
declare namespace Selector {
15+
type Trim<T extends string> = T extends `${infer S} ` ? Trim<S> : T extends ` ${infer S}` ? Trim<S> : T;
16+
17+
type SolveHead<Head extends string | undefined> = Head extends string
18+
? Trim<Head> extends infer C extends keyof Instances
19+
? Instances[C]
20+
: Instance
21+
: Instance;
22+
23+
type AddSolvedHead<Result, Head extends string | undefined> = [Result] extends [never]
24+
? SolveHead<Head>
25+
: Result | SolveHead<Head>;
26+
27+
type HeadBreak = ":" | "." | "#" | "[" | " ";
28+
29+
type ReadHead<
30+
Head extends string | undefined = undefined,
31+
Done extends boolean = false,
32+
C extends string = "",
33+
> = Done extends true
34+
? [Head, Done]
35+
: Head extends undefined
36+
? Trim<C> extends ""
37+
? [undefined, false]
38+
: C extends HeadBreak
39+
? ["", true]
40+
: [C, false]
41+
: C extends HeadBreak
42+
? [Head, true]
43+
: [`${Head}${C}`, false];
44+
45+
type SolveChars<
46+
S extends string,
47+
Head extends string | undefined = undefined,
48+
Done extends boolean = false,
49+
Result = never,
50+
Depth extends Array<any> = [],
51+
> = S extends `${infer C}${infer Rest}`
52+
? Depth extends []
53+
? C extends "("
54+
? SolveChars<Rest, Head, Done, Result, [any]>
55+
: C extends ","
56+
? SolveChars<Rest, undefined, false, AddSolvedHead<Result, Head>, Depth>
57+
: C extends ">"
58+
? SolveChars<Rest, undefined, false, Result, Depth>
59+
: ReadHead<Head, Done, C> extends [
60+
infer NextHead extends string | undefined,
61+
infer NextDone extends boolean,
62+
]
63+
? SolveChars<Rest, NextHead, NextDone, Result, Depth>
64+
: never
65+
: C extends "("
66+
? SolveChars<Rest, Head, Done, Result, [...Depth, any]>
67+
: C extends ")"
68+
? SolveChars<Rest, Head, Done, Result, Depth extends [any, ...infer D] ? D : []>
69+
: SolveChars<Rest, Head, Done, Result, Depth>
70+
: AddSolvedHead<Result, Head>;
71+
72+
// Nested pseudo-classes can contain selector lists, so this path tracks only top-level
73+
// separators while keeping the subject class from the current segment.
74+
type SlowSolve<T extends string> = SolveChars<T>;
75+
76+
// Only the final combinator segment can determine the subject type.
77+
type LastSegment<S extends string> = S extends `${string}>${infer R}` ? LastSegment<R> : S;
78+
79+
type CutAt<S extends string, D extends string> = S extends `${infer Prefix}${D}${string}` ? Prefix : S;
80+
81+
type LeadingClass<S extends string> = CutAt<CutAt<CutAt<CutAt<Trim<S>, ":">, ".">, "#">, " ">;
82+
83+
// Filters do not affect the subject class, but their values can contain selector separators.
84+
type StripFiltersAndParens<S extends string> = S extends `${infer A}[${string}]${infer B}`
85+
? StripFiltersAndParens<`${A}${B}`>
86+
: S extends `${infer A}(${string})${infer B}`
87+
? StripFiltersAndParens<`${A}${B}`>
88+
: S;
89+
90+
type FastClause<S extends string> =
91+
LeadingClass<LastSegment<S>> extends infer C extends keyof Instances ? Instances[C] : Instance;
92+
93+
type FastSolve<S extends string> = S extends `${infer A},${infer B}` ? FastClause<A> | FastSolve<B> : FastClause<S>;
94+
95+
type SolveUnquoted<S extends string> = S extends `${string}(${string}(${string})${string})${string}`
96+
? SlowSolve<S>
97+
: FastSolve<StripFiltersAndParens<S>>;
98+
99+
type StripQuotes<S extends string> = S extends `${infer A}'${string}'${infer B}` ? StripQuotes<`${A}${B}`> : S;
100+
101+
// Attribute values are not selector syntax, even when they contain ":" or ",".
102+
type StripValidationGroups<S extends string> = S extends `${infer A}'${string}'${infer B}`
103+
? StripValidationGroups<`${A}${B}`>
104+
: S extends `${infer A}[${string}]${infer B}`
105+
? StripValidationGroups<`${A}${B}`>
106+
: S;
107+
108+
type SupportedPseudo = "not" | "has";
109+
110+
type CheckPseudos<S extends string> = S extends `${string}:${infer R}`
111+
? R extends `${infer Name}(${infer Rest}`
112+
? Name extends SupportedPseudo
113+
? CheckPseudos<Rest>
114+
: Name
115+
: R // ':' not followed by 'name(' -> pseudo-classes require arguments
116+
: never;
117+
118+
// The whole-empty selector "" is intentionally allowed.
119+
type HasEmptyListItem<S extends string> = S extends `${string},${string}` ? CheckListItems<S> : false;
120+
type CheckListItems<S extends string> = S extends `${infer A},${infer B}`
121+
? Trim<A> extends ""
122+
? true
123+
: CheckListItems<B>
124+
: Trim<S> extends ""
125+
? true
126+
: false;
127+
128+
type ValidateUnquoted<S extends string, Q extends string> = Q extends `${string}:${string}`
129+
? CheckPseudos<Q> extends infer Bad
130+
? [Bad] extends [never]
131+
? HasEmptyListItem<Q> extends true
132+
? `Invalid selector: empty selector in list (check for a stray or trailing comma)`
133+
: S
134+
: `Invalid selector: ':${Bad & string}' is not a supported pseudo-class (only ':not()' and ':has()' are allowed)`
135+
: never
136+
: Q extends `${string},${string}`
137+
? HasEmptyListItem<Q> extends true
138+
? `Invalid selector: empty selector in list (check for a stray or trailing comma)`
139+
: S
140+
: S;
141+
142+
export type ValidateSelector<S extends string> = string extends S
143+
? S
144+
: StripValidationGroups<S> extends infer Q extends string
145+
? ValidateUnquoted<S, Q>
146+
: never;
147+
148+
// Quoted values are stripped before solving so most selectors can stay on the cheaper path.
149+
export type Solve<S extends string> = S extends `${string}'${string}`
150+
? SolveUnquoted<StripQuotes<S>>
151+
: SolveUnquoted<S>;
152+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"scripts": {
99
"build": "tsc && node out/index.js",
1010
"check": "tsc include/**/*.d.ts --noEmit --skipLibCheck",
11+
"test": "tsc && node out/runTests.js",
1112
"eslint-src": "npx eslint \"src/**/*.ts\" --max-warnings 0",
1213
"eslint-include": "npx eslint \"include/*.ts\" --max-warnings 0",
1314
"eslint": "npm run eslint-src && npm run eslint-include"

0 commit comments

Comments
 (0)