Skip to content

Commit 519833e

Browse files
authored
Merge pull request #3218 from github/koesie10/parse-access-paths
Add functions for parsing and validating access paths
2 parents 24dff59 + ea6e148 commit 519833e

2 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* This file contains functions for parsing and validating access paths.
3+
*
4+
* This intentionally does not simply split by '.' since tokens may contain dots,
5+
* e.g. `Field[foo.Bar.x]`. Instead, it uses some simple parsing to match valid tokens.
6+
*
7+
* Valid syntax was determined based on this file:
8+
* https://github.com/github/codeql/blob/a04830b8b2d3e5f7df8e1f80f06c020b987a89a3/ruby/ql/lib/codeql/ruby/dataflow/internal/AccessPathSyntax.qll
9+
*
10+
* In contrast to that file, we do not use a regex for parsing to allow us to be more lenient.
11+
* For example, we can parse partial access paths such as `Field[foo.Bar.x` without error.
12+
*/
13+
14+
/**
15+
* A range of characters in an access path. The start position is inclusive, the end position is exclusive.
16+
*/
17+
type AccessPathRange = {
18+
/**
19+
* Zero-based index of the first character of the token.
20+
*/
21+
start: number;
22+
/**
23+
* Zero-based index of the character after the last character of the token.
24+
*/
25+
end: number;
26+
};
27+
28+
/**
29+
* A token in an access path. For example, `Argument[foo]` is a token.
30+
*/
31+
type AccessPartToken = {
32+
text: string;
33+
range: AccessPathRange;
34+
};
35+
36+
/**
37+
* Parses an access path into tokens.
38+
*
39+
* @param path The access path to parse.
40+
* @returns An array of tokens.
41+
*/
42+
export function parseAccessPathTokens(path: string): AccessPartToken[] {
43+
const parts: AccessPartToken[] = [];
44+
45+
let currentPart = "";
46+
let currentPathStart = 0;
47+
// Keep track of the number of brackets we can parse the path correctly when it contains
48+
// nested brackets such as `Argument[foo[bar].test].Element`.
49+
let bracketCounter = 0;
50+
for (let i = 0; i < path.length; i++) {
51+
const c = path[i];
52+
53+
if (c === "[") {
54+
bracketCounter++;
55+
} else if (c === "]") {
56+
bracketCounter--;
57+
} else if (c === "." && bracketCounter === 0) {
58+
// A part ends when we encounter a dot that is not inside brackets.
59+
parts.push({
60+
text: currentPart,
61+
range: {
62+
start: currentPathStart,
63+
end: i,
64+
},
65+
});
66+
currentPart = "";
67+
currentPathStart = i + 1;
68+
continue;
69+
}
70+
71+
currentPart += c;
72+
}
73+
74+
// The last part should not be followed by a dot, so we need to add it manually.
75+
// If the path is empty, such as for `Argument[foo].`, then this is still correct
76+
// since the `validateAccessPath` function will check that none of the tokens are
77+
// empty.
78+
parts.push({
79+
text: currentPart,
80+
range: {
81+
start: currentPathStart,
82+
end: path.length,
83+
},
84+
});
85+
86+
return parts;
87+
}
88+
89+
// Regex for a single part of the access path
90+
const tokenRegex = /^(\w+)(?:\[([^\]]*)])?$/;
91+
92+
type AccessPathDiagnostic = {
93+
range: AccessPathRange;
94+
message: string;
95+
};
96+
97+
/**
98+
* Validates an access path and returns any errors. This requires that the path is a valid path
99+
* and does not allow partial access paths.
100+
*
101+
* @param path The access path to validate.
102+
* @returns An array of diagnostics for any errors in the access path.
103+
*/
104+
export function validateAccessPath(path: string): AccessPathDiagnostic[] {
105+
if (path === "") {
106+
return [];
107+
}
108+
109+
const tokens = parseAccessPathTokens(path);
110+
111+
return tokens
112+
.map((token): AccessPathDiagnostic | null => {
113+
if (tokenRegex.test(token.text)) {
114+
return null;
115+
}
116+
117+
let message = "Invalid access path";
118+
if (token.range.start === token.range.end) {
119+
message = "Unexpected empty token";
120+
}
121+
122+
return {
123+
range: token.range,
124+
message,
125+
};
126+
})
127+
.filter((token): token is AccessPathDiagnostic => token !== null);
128+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import {
2+
parseAccessPathTokens,
3+
validateAccessPath,
4+
} from "../../../../src/model-editor/shared/access-paths";
5+
6+
describe("parseAccessPathTokens", () => {
7+
it.each([
8+
{
9+
path: "Argument[foo].Element.Field[@test]",
10+
parts: [
11+
{
12+
range: {
13+
start: 0,
14+
end: 13,
15+
},
16+
text: "Argument[foo]",
17+
},
18+
{
19+
range: {
20+
start: 14,
21+
end: 21,
22+
},
23+
text: "Element",
24+
},
25+
{
26+
range: {
27+
start: 22,
28+
end: 34,
29+
},
30+
text: "Field[@test]",
31+
},
32+
],
33+
},
34+
{
35+
path: "Argument[foo].Element.Field[foo.Bar.x]",
36+
parts: [
37+
{
38+
range: {
39+
start: 0,
40+
end: 13,
41+
},
42+
text: "Argument[foo]",
43+
},
44+
{
45+
range: {
46+
start: 14,
47+
end: 21,
48+
},
49+
text: "Element",
50+
},
51+
{
52+
range: {
53+
start: 22,
54+
end: 38,
55+
},
56+
text: "Field[foo.Bar.x]",
57+
},
58+
],
59+
},
60+
{
61+
path: "Argument[",
62+
parts: [
63+
{
64+
range: {
65+
start: 0,
66+
end: 9,
67+
},
68+
text: "Argument[",
69+
},
70+
],
71+
},
72+
{
73+
path: "Argument[se",
74+
parts: [
75+
{
76+
range: {
77+
start: 0,
78+
end: 11,
79+
},
80+
text: "Argument[se",
81+
},
82+
],
83+
},
84+
{
85+
path: "Argument[foo].Field[",
86+
parts: [
87+
{
88+
range: {
89+
start: 0,
90+
end: 13,
91+
},
92+
text: "Argument[foo]",
93+
},
94+
{
95+
range: {
96+
start: 14,
97+
end: 20,
98+
},
99+
text: "Field[",
100+
},
101+
],
102+
},
103+
{
104+
path: "Argument[foo].",
105+
parts: [
106+
{
107+
text: "Argument[foo]",
108+
range: {
109+
end: 13,
110+
start: 0,
111+
},
112+
},
113+
{
114+
text: "",
115+
range: {
116+
end: 14,
117+
start: 14,
118+
},
119+
},
120+
],
121+
},
122+
{
123+
path: "Argument[foo]..",
124+
parts: [
125+
{
126+
text: "Argument[foo]",
127+
range: {
128+
end: 13,
129+
start: 0,
130+
},
131+
},
132+
{
133+
text: "",
134+
range: {
135+
end: 14,
136+
start: 14,
137+
},
138+
},
139+
{
140+
text: "",
141+
range: {
142+
end: 15,
143+
start: 15,
144+
},
145+
},
146+
],
147+
},
148+
{
149+
path: "Argument[foo[bar].test].Element.",
150+
parts: [
151+
{
152+
range: {
153+
start: 0,
154+
end: 23,
155+
},
156+
text: "Argument[foo[bar].test]",
157+
},
158+
{
159+
range: {
160+
start: 24,
161+
end: 31,
162+
},
163+
text: "Element",
164+
},
165+
{
166+
range: {
167+
start: 32,
168+
end: 32,
169+
},
170+
text: "",
171+
},
172+
],
173+
},
174+
])(`parses correctly for $path`, ({ path, parts }) => {
175+
expect(parseAccessPathTokens(path)).toEqual(parts);
176+
});
177+
});
178+
179+
describe("validateAccessPath", () => {
180+
it.each([
181+
{
182+
path: "Argument[foo].Element.Field[@test]",
183+
diagnostics: [],
184+
},
185+
{
186+
path: "Argument[foo].Element.Field[foo.Bar.x]",
187+
diagnostics: [],
188+
},
189+
{
190+
path: "Argument[",
191+
diagnostics: [
192+
{
193+
message: "Invalid access path",
194+
range: {
195+
start: 0,
196+
end: 9,
197+
},
198+
},
199+
],
200+
},
201+
{
202+
path: "Argument[se",
203+
diagnostics: [
204+
{
205+
message: "Invalid access path",
206+
range: {
207+
start: 0,
208+
end: 11,
209+
},
210+
},
211+
],
212+
},
213+
{
214+
path: "Argument[foo].Field[",
215+
diagnostics: [
216+
{
217+
message: "Invalid access path",
218+
range: {
219+
start: 14,
220+
end: 20,
221+
},
222+
},
223+
],
224+
},
225+
{
226+
path: "Argument[foo].",
227+
diagnostics: [
228+
{ message: "Unexpected empty token", range: { start: 14, end: 14 } },
229+
],
230+
},
231+
{
232+
path: "Argument[foo]..",
233+
diagnostics: [
234+
{ message: "Unexpected empty token", range: { start: 14, end: 14 } },
235+
{ message: "Unexpected empty token", range: { start: 15, end: 15 } },
236+
],
237+
},
238+
{
239+
path: "Argument[foo[bar].test].Element.",
240+
diagnostics: [
241+
{ message: "Invalid access path", range: { start: 0, end: 23 } },
242+
{ message: "Unexpected empty token", range: { start: 32, end: 32 } },
243+
],
244+
},
245+
])(
246+
`validates $path correctly with $diagnostics.length errors`,
247+
({ path, diagnostics }) => {
248+
expect(validateAccessPath(path)).toEqual(diagnostics);
249+
},
250+
);
251+
});

0 commit comments

Comments
 (0)