Skip to content

Commit 8c679ab

Browse files
authored
CodeQL model editor: Add functions for parsing complex access path suggestion options (#3292)
1 parent 0391f97 commit 8c679ab

File tree

3 files changed

+346
-0
lines changed

3 files changed

+346
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { parseAccessPathTokens } from "./shared/access-paths";
2+
import type { AccessPathOption, AccessPathSuggestionRow } from "./suggestions";
3+
import { AccessPathSuggestionDefinitionType } from "./suggestions";
4+
5+
const CodiconSymbols: Record<AccessPathSuggestionDefinitionType, string> = {
6+
[AccessPathSuggestionDefinitionType.Array]: "symbol-array",
7+
[AccessPathSuggestionDefinitionType.Class]: "symbol-class",
8+
[AccessPathSuggestionDefinitionType.Enum]: "symbol-enum",
9+
[AccessPathSuggestionDefinitionType.EnumMember]: "symbol-enum-member",
10+
[AccessPathSuggestionDefinitionType.Field]: "symbol-field",
11+
[AccessPathSuggestionDefinitionType.Interface]: "symbol-interface",
12+
[AccessPathSuggestionDefinitionType.Key]: "symbol-key",
13+
[AccessPathSuggestionDefinitionType.Method]: "symbol-method",
14+
[AccessPathSuggestionDefinitionType.Misc]: "symbol-misc",
15+
[AccessPathSuggestionDefinitionType.Namespace]: "symbol-namespace",
16+
[AccessPathSuggestionDefinitionType.Parameter]: "symbol-parameter",
17+
[AccessPathSuggestionDefinitionType.Property]: "symbol-property",
18+
[AccessPathSuggestionDefinitionType.Structure]: "symbol-structure",
19+
[AccessPathSuggestionDefinitionType.Return]: "symbol-method",
20+
[AccessPathSuggestionDefinitionType.Variable]: "symbol-variable",
21+
};
22+
23+
/**
24+
* Parses the query results from a parsed array of rows to a list of options per method signature.
25+
*
26+
* @param rows The parsed rows from the BQRS chunk
27+
* @return A map from method signature -> options
28+
*/
29+
export function parseAccessPathSuggestionRowsToOptions(
30+
rows: AccessPathSuggestionRow[],
31+
): Record<string, AccessPathOption[]> {
32+
const rowsByMethodSignature = new Map<string, AccessPathSuggestionRow[]>();
33+
34+
for (const row of rows) {
35+
if (!rowsByMethodSignature.has(row.method.signature)) {
36+
rowsByMethodSignature.set(row.method.signature, []);
37+
}
38+
39+
const tuplesForMethodSignature = rowsByMethodSignature.get(
40+
row.method.signature,
41+
);
42+
if (!tuplesForMethodSignature) {
43+
throw new Error("Expected the map to have a value for method signature");
44+
}
45+
46+
tuplesForMethodSignature.push(row);
47+
}
48+
49+
const result: Record<string, AccessPathOption[]> = {};
50+
51+
for (const [methodSignature, tuples] of rowsByMethodSignature) {
52+
result[methodSignature] = parseQueryResultsForPath(tuples);
53+
}
54+
55+
return result;
56+
}
57+
58+
function parseQueryResultsForPath(
59+
rows: AccessPathSuggestionRow[],
60+
): AccessPathOption[] {
61+
const optionsByParentPath = new Map<string, AccessPathOption[]>();
62+
63+
for (const { value, details, definitionType } of rows) {
64+
const tokens = parseAccessPathTokens(value);
65+
const lastToken = tokens[tokens.length - 1];
66+
67+
const parentPath = tokens
68+
.slice(0, tokens.length - 1)
69+
.map((token) => token.text)
70+
.join(".");
71+
72+
const option: AccessPathOption = {
73+
label: lastToken.text,
74+
value,
75+
details,
76+
icon: CodiconSymbols[definitionType],
77+
followup: [],
78+
};
79+
80+
if (!optionsByParentPath.has(parentPath)) {
81+
optionsByParentPath.set(parentPath, []);
82+
}
83+
84+
const options = optionsByParentPath.get(parentPath);
85+
if (!options) {
86+
throw new Error(
87+
"Expected optionsByParentPath to have a value for parentPath",
88+
);
89+
}
90+
91+
options.push(option);
92+
}
93+
94+
for (const options of optionsByParentPath.values()) {
95+
options.sort(compareOptions);
96+
}
97+
98+
for (const options of optionsByParentPath.values()) {
99+
for (const option of options) {
100+
const followup = optionsByParentPath.get(option.value);
101+
if (followup) {
102+
option.followup = followup;
103+
}
104+
}
105+
}
106+
107+
const rootOptions = optionsByParentPath.get("");
108+
if (!rootOptions) {
109+
throw new Error("Expected optionsByParentPath to have a value for ''");
110+
}
111+
112+
return rootOptions;
113+
}
114+
115+
/**
116+
* Compares two options based on a set of predefined rules.
117+
*
118+
* The rules are as follows:
119+
* - Argument[self] is always first
120+
* - Positional arguments (Argument[0], Argument[1], etc.) are sorted in order and are after Argument[self]
121+
* - Keyword arguments (Argument[key:], etc.) are sorted by name and are after the positional arguments
122+
* - Block arguments (Argument[block]) are sorted after keyword arguments
123+
* - Hash splat arguments (Argument[hash-splat]) are sorted after block arguments
124+
* - Parameters (Parameter[0], Parameter[1], etc.) are sorted after and in-order
125+
* - All other values are sorted alphabetically after parameters
126+
*
127+
* @param {Option} a - The first option to compare.
128+
* @param {Option} b - The second option to compare.
129+
* @returns {number} - Returns -1 if a < b, 1 if a > b, 0 if a = b.
130+
*/
131+
function compareOptions(a: AccessPathOption, b: AccessPathOption): number {
132+
const positionalArgRegex = /^Argument\[\d+]$/;
133+
const keywordArgRegex = /^Argument\[[^\d:]+:]$/;
134+
const parameterRegex = /^Parameter\[\d+]$/;
135+
136+
// Check for Argument[self]
137+
if (a.label === "Argument[self]" && b.label !== "Argument[self]") {
138+
return -1;
139+
} else if (b.label === "Argument[self]" && a.label !== "Argument[self]") {
140+
return 1;
141+
}
142+
143+
// Check for positional arguments
144+
const aIsPositional = positionalArgRegex.test(a.label);
145+
const bIsPositional = positionalArgRegex.test(b.label);
146+
if (aIsPositional && bIsPositional) {
147+
return a.label.localeCompare(b.label, "en-US", { numeric: true });
148+
} else if (aIsPositional) {
149+
return -1;
150+
} else if (bIsPositional) {
151+
return 1;
152+
}
153+
154+
// Check for keyword arguments
155+
const aIsKeyword = keywordArgRegex.test(a.label);
156+
const bIsKeyword = keywordArgRegex.test(b.label);
157+
if (aIsKeyword && bIsKeyword) {
158+
return a.label.localeCompare(b.label, "en-US");
159+
} else if (aIsKeyword) {
160+
return -1;
161+
} else if (bIsKeyword) {
162+
return 1;
163+
}
164+
165+
// Check for Argument[block]
166+
if (a.label === "Argument[block]" && b.label !== "Argument[block]") {
167+
return -1;
168+
} else if (b.label === "Argument[block]" && a.label !== "Argument[block]") {
169+
return 1;
170+
}
171+
172+
// Check for Argument[hash-splat]
173+
if (
174+
a.label === "Argument[hash-splat]" &&
175+
b.label !== "Argument[hash-splat]"
176+
) {
177+
return -1;
178+
} else if (
179+
b.label === "Argument[hash-splat]" &&
180+
a.label !== "Argument[hash-splat]"
181+
) {
182+
return 1;
183+
}
184+
185+
// Check for parameters
186+
const aIsParameter = parameterRegex.test(a.label);
187+
const bIsParameter = parameterRegex.test(b.label);
188+
if (aIsParameter && bIsParameter) {
189+
return a.label.localeCompare(b.label, "en-US", { numeric: true });
190+
} else if (aIsParameter) {
191+
return -1;
192+
} else if (bIsParameter) {
193+
return 1;
194+
}
195+
196+
// If none of the above rules apply, compare alphabetically
197+
return a.label.localeCompare(b.label, "en-US");
198+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { MethodSignature } from "./method";
2+
3+
export enum AccessPathSuggestionDefinitionType {
4+
Array = "array",
5+
Class = "class",
6+
Enum = "enum",
7+
EnumMember = "enum-member",
8+
Field = "field",
9+
Interface = "interface",
10+
Key = "key",
11+
Method = "method",
12+
Misc = "misc",
13+
Namespace = "namespace",
14+
Parameter = "parameter",
15+
Property = "property",
16+
Structure = "structure",
17+
Return = "return",
18+
Variable = "variable",
19+
}
20+
21+
export type AccessPathSuggestionRow = {
22+
method: MethodSignature;
23+
definitionType: AccessPathSuggestionDefinitionType;
24+
value: string;
25+
details: string;
26+
};
27+
28+
export type AccessPathOption = {
29+
label: string;
30+
value: string;
31+
icon: string;
32+
details?: string;
33+
followup?: AccessPathOption[];
34+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { AccessPathSuggestionRow } from "../../../src/model-editor/suggestions";
2+
import { parseAccessPathSuggestionRowsToOptions } from "../../../src/model-editor/suggestions-bqrs";
3+
4+
describe("parseAccessPathSuggestionRowsToOptions", () => {
5+
const rows = [
6+
{
7+
method: {
8+
packageName: "",
9+
typeName: "Jekyll::Utils",
10+
methodName: "transform_keys",
11+
methodParameters: "",
12+
signature: "Jekyll::Utils#transform_keys",
13+
},
14+
value: "Argument[0]",
15+
details: "hash",
16+
definitionType: "parameter",
17+
},
18+
{
19+
method: {
20+
packageName: "",
21+
typeName: "Jekyll::Utils",
22+
methodName: "transform_keys",
23+
methodParameters: "",
24+
signature: "Jekyll::Utils#transform_keys",
25+
},
26+
value: "ReturnValue",
27+
details: "result",
28+
definitionType: "return",
29+
},
30+
{
31+
method: {
32+
packageName: "",
33+
typeName: "Jekyll::Utils",
34+
methodName: "transform_keys",
35+
methodParameters: "",
36+
signature: "Jekyll::Utils#transform_keys",
37+
},
38+
value: "Argument[self]",
39+
details: "self in transform_keys",
40+
definitionType: "parameter",
41+
},
42+
{
43+
method: {
44+
packageName: "",
45+
typeName: "Jekyll::Utils",
46+
methodName: "transform_keys",
47+
methodParameters: "",
48+
signature: "Jekyll::Utils#transform_keys",
49+
},
50+
value: "Argument[block].Parameter[0]",
51+
details: "key",
52+
definitionType: "parameter",
53+
},
54+
{
55+
method: {
56+
packageName: "",
57+
typeName: "Jekyll::Utils",
58+
methodName: "transform_keys",
59+
methodParameters: "",
60+
signature: "Jekyll::Utils#transform_keys",
61+
},
62+
value: "Argument[block]",
63+
details: "yield ...",
64+
definitionType: "parameter",
65+
},
66+
] as AccessPathSuggestionRow[];
67+
68+
it("should parse the AccessPathSuggestionRows", async () => {
69+
// Note that the order of these options matters
70+
const expectedOptions = {
71+
"Jekyll::Utils#transform_keys": [
72+
{
73+
label: "Argument[self]",
74+
value: "Argument[self]",
75+
details: "self in transform_keys",
76+
icon: "symbol-parameter",
77+
followup: [],
78+
},
79+
{
80+
label: "Argument[0]",
81+
value: "Argument[0]",
82+
details: "hash",
83+
icon: "symbol-parameter",
84+
followup: [],
85+
},
86+
{
87+
label: "Argument[block]",
88+
value: "Argument[block]",
89+
details: "yield ...",
90+
icon: "symbol-parameter",
91+
followup: [
92+
{
93+
label: "Parameter[0]",
94+
value: "Argument[block].Parameter[0]",
95+
details: "key",
96+
icon: "symbol-parameter",
97+
followup: [],
98+
},
99+
],
100+
},
101+
{
102+
label: "ReturnValue",
103+
value: "ReturnValue",
104+
details: "result",
105+
icon: "symbol-method",
106+
followup: [],
107+
},
108+
],
109+
};
110+
111+
const options = parseAccessPathSuggestionRowsToOptions(rows);
112+
expect(options).toEqual(expectedOptions);
113+
});
114+
});

0 commit comments

Comments
 (0)