Skip to content

Commit ea6e148

Browse files
committed
Add functions for parsing and validating access paths
This adds functions for parsing and validating access paths to prepare for future functionality where we're going to be parsing and validating access paths.
1 parent 1b73767 commit ea6e148

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)