Skip to content

Commit 6bd7e13

Browse files
authored
Qualified Paths (#2429)
* implementation and tests * utility functions to read files from paths
1 parent 258b8f5 commit 6bd7e13

File tree

2 files changed

+344
-0
lines changed

2 files changed

+344
-0
lines changed

src/core/qualified-path.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/*
2+
* qualified-path.ts
3+
*
4+
* Path objects that hold additional information about their status
5+
*
6+
* Copyright (C) 2022 by RStudio, PBC
7+
*
8+
*/
9+
10+
import { join, relative, resolve } from "path/mod.ts";
11+
12+
export class InvalidPathError extends Error {
13+
constructor(msg: string) {
14+
super(msg);
15+
}
16+
}
17+
18+
export type PathInfo = {
19+
projectRoot: string;
20+
currentFileDir: string;
21+
};
22+
23+
export type PathType = "project-relative" | "relative" | "absolute";
24+
25+
type BasePath = {
26+
value: string;
27+
asAbsolute: (info?: PathInfo) => AbsolutePath;
28+
asRelative: (info?: PathInfo) => RelativePath;
29+
asProjectRelative: (info?: PathInfo) => ProjectRelativePath;
30+
};
31+
32+
export type QualifiedPath = BasePath & { type?: PathType };
33+
export type AbsolutePath = BasePath & { type: "absolute" };
34+
export type RelativePath = BasePath & { type: "relative" };
35+
export type ProjectRelativePath = BasePath & { type: "project-relative" };
36+
37+
export function makePath(
38+
path: string,
39+
info?: PathInfo,
40+
forceAbsolute?: boolean,
41+
): QualifiedPath {
42+
const type = path.startsWith("/")
43+
? (forceAbsolute ? "absolute" : "project-relative")
44+
: "relative";
45+
46+
const result: QualifiedPath = {
47+
value: path,
48+
type,
49+
asAbsolute(info?: PathInfo) {
50+
return toAbsolutePath(this, info);
51+
},
52+
asRelative(info?: PathInfo) {
53+
return toRelativePath(this, info);
54+
},
55+
asProjectRelative(info?: PathInfo) {
56+
return toProjectRelativePath(this, info);
57+
},
58+
};
59+
60+
// we call asAbsolute() at least once on each path so
61+
// that the path is validated; this is simply
62+
// so that exceptions can be raised appropriately.
63+
const quartoPaths: PathInfo = resolvePathInfo(info);
64+
result.asAbsolute(quartoPaths);
65+
66+
return result;
67+
}
68+
69+
export function readTextFile(t: QualifiedPath, options?: Deno.ReadFileOptions) {
70+
return Deno.readTextFile(t.asAbsolute().value, options);
71+
}
72+
73+
export function readTextFileSync(
74+
t: QualifiedPath,
75+
) {
76+
return Deno.readTextFileSync(t.asAbsolute().value);
77+
}
78+
79+
// validates an absolute path
80+
function validate(value: string, quartoPaths: PathInfo): string {
81+
if (!value.startsWith(quartoPaths.projectRoot)) {
82+
throw new InvalidPathError(
83+
"Paths cannot resolve outside of document or project root",
84+
);
85+
}
86+
return value;
87+
}
88+
89+
function toAbsolutePath(
90+
path: QualifiedPath,
91+
info?: PathInfo,
92+
): AbsolutePath {
93+
let value: string;
94+
95+
if (isAbsolutePath(path)) {
96+
return path;
97+
}
98+
99+
const quartoPaths: PathInfo = resolvePathInfo(info);
100+
101+
switch (path.type) {
102+
case "project-relative":
103+
// project-relative -> absolute
104+
value = resolve(join(quartoPaths.projectRoot, path.value));
105+
break;
106+
case "relative":
107+
// relative -> absolute
108+
value = resolve(join(quartoPaths.currentFileDir, path.value));
109+
break;
110+
default:
111+
if (path.value.startsWith("/")) {
112+
// project-relative -> absolute
113+
value = resolve(join(quartoPaths.projectRoot, path.value));
114+
} else {
115+
// relative -> absolute
116+
value = resolve(join(quartoPaths.currentFileDir, path.value));
117+
}
118+
}
119+
value = validate(value, quartoPaths);
120+
121+
return {
122+
value,
123+
type: "absolute",
124+
asAbsolute(_info?: PathInfo) {
125+
return this;
126+
},
127+
asRelative(info?: PathInfo) {
128+
return toRelativePath(this, info);
129+
},
130+
asProjectRelative(info?: PathInfo) {
131+
return toProjectRelativePath(this, info);
132+
},
133+
};
134+
}
135+
136+
function toRelativePath(
137+
path: QualifiedPath,
138+
info?: PathInfo,
139+
): RelativePath {
140+
let value: string;
141+
142+
if (isRelativePath(path)) {
143+
return path;
144+
}
145+
146+
const quartoPaths: PathInfo = resolvePathInfo(info);
147+
148+
switch (path.type) {
149+
case "absolute":
150+
// absolute -> relative
151+
value = relative(quartoPaths.currentFileDir, path.value);
152+
break;
153+
case "project-relative": {
154+
// project-relative -> absolute -> relative
155+
const absPath = validate(
156+
resolve(join(quartoPaths.projectRoot, path.value)),
157+
quartoPaths,
158+
);
159+
value = relative(
160+
quartoPaths.currentFileDir,
161+
absPath,
162+
);
163+
break;
164+
}
165+
default:
166+
if (path.value.startsWith("/")) {
167+
// project-relative -> absolute -> relative
168+
const absPath = validate(
169+
resolve(join(quartoPaths.projectRoot, path.value)),
170+
quartoPaths,
171+
);
172+
value = relative(
173+
quartoPaths.currentFileDir,
174+
absPath,
175+
);
176+
} else {
177+
throw new Error("Internal Error, should never arrive here.");
178+
}
179+
}
180+
181+
return {
182+
value,
183+
type: "relative",
184+
asAbsolute(info?: PathInfo) {
185+
return toAbsolutePath(this, info);
186+
},
187+
asRelative(_info?: PathInfo) {
188+
return this;
189+
},
190+
asProjectRelative(info?: PathInfo) {
191+
return toProjectRelativePath(this, info);
192+
},
193+
};
194+
}
195+
196+
function toProjectRelativePath(
197+
path: QualifiedPath,
198+
info?: PathInfo,
199+
): ProjectRelativePath {
200+
let value: string;
201+
202+
if (isProjectRelativePath(path)) {
203+
return path;
204+
}
205+
206+
const quartoPaths: PathInfo = resolvePathInfo(info);
207+
208+
switch (path.type) {
209+
case "absolute":
210+
// absolute -> project-relative
211+
value = `/${relative(quartoPaths.projectRoot, path.value)}`;
212+
break;
213+
case "relative":
214+
// relative -> absolute -> project-relative
215+
value = `/${
216+
relative(
217+
quartoPaths.projectRoot,
218+
validate(
219+
resolve(join(quartoPaths.currentFileDir, path.value)),
220+
quartoPaths,
221+
),
222+
)
223+
}`;
224+
break;
225+
default:
226+
if (!path.value.startsWith("/")) {
227+
throw new Error("Internal Error, should never arrive here.");
228+
} else {
229+
// relative -> absolute -> project-relative
230+
value = `/${
231+
relative(
232+
quartoPaths.projectRoot,
233+
validate(
234+
resolve(join(quartoPaths.currentFileDir, path.value)),
235+
quartoPaths,
236+
),
237+
)
238+
}`;
239+
}
240+
}
241+
242+
return {
243+
value,
244+
type: "project-relative",
245+
asAbsolute(info?: PathInfo) {
246+
return toAbsolutePath(this, info);
247+
},
248+
asProjectRelative(_info?: PathInfo) {
249+
return this;
250+
},
251+
asRelative(info?: PathInfo) {
252+
return toRelativePath(this, info);
253+
},
254+
};
255+
}
256+
257+
function resolvePathInfo(path?: PathInfo): PathInfo {
258+
if (path !== undefined) {
259+
return path;
260+
}
261+
return {} as any; // FIXME this should get information from quarto's runtime.
262+
}
263+
264+
function isRelativePath(path: QualifiedPath): path is RelativePath {
265+
return (path.type === "relative") ||
266+
(path.type === undefined && !path.value.startsWith("/"));
267+
}
268+
269+
function isProjectRelativePath(
270+
path: QualifiedPath,
271+
): path is ProjectRelativePath {
272+
return (path.type === "project-relative") ||
273+
(path.type === undefined && path.value.startsWith("/"));
274+
}
275+
276+
function isAbsolutePath(path: QualifiedPath): path is AbsolutePath {
277+
return path.type === "absolute";
278+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* qualified-path.test.ts
3+
*
4+
* Copyright (C) 2022 by RStudio, PBC
5+
*
6+
*/
7+
8+
import { unitTest } from "../../test.ts";
9+
10+
import {
11+
makePath,
12+
PathInfo,
13+
QualifiedPath,
14+
} from "../../../src/core/qualified-path.ts";
15+
import {
16+
assertEquals,
17+
assertRejects,
18+
} from "../../../src/vendor/deno.land/[email protected]/testing/asserts.ts";
19+
20+
//deno-lint-ignore require-await
21+
unitTest("qualified-path - basic", async () => {
22+
const paths: PathInfo = {
23+
currentFileDir: "/tmp/project/dir",
24+
projectRoot: "/tmp/project",
25+
};
26+
27+
const projectRelative = makePath("/dir/file1", paths);
28+
const relative = makePath("file1", paths);
29+
const absolute = makePath("/tmp/project/dir/file1", paths, true);
30+
31+
const expectedRelative = "file1";
32+
const expectedProjectRelative = "/dir/file1";
33+
const expectedAbsolute = "/tmp/project/dir/file1";
34+
35+
for (let path of [projectRelative, relative, absolute]) {
36+
for (let i = 0; i < 30; ++i) {
37+
const choices = [
38+
(path: QualifiedPath) => path.asAbsolute(paths),
39+
(path: QualifiedPath) => path.asRelative(paths),
40+
(path: QualifiedPath) => path.asProjectRelative(paths),
41+
];
42+
path = choices[~~(Math.random() * choices.length)](path);
43+
assertEquals(path.asAbsolute(paths).value, expectedAbsolute);
44+
assertEquals(path.asRelative(paths).value, expectedRelative);
45+
assertEquals(
46+
path.asProjectRelative(paths).value,
47+
expectedProjectRelative,
48+
);
49+
}
50+
}
51+
});
52+
53+
unitTest("qualified-path - validation", async () => {
54+
const paths: PathInfo = {
55+
currentFileDir: "/tmp/project/dir",
56+
projectRoot: "/tmp/project",
57+
};
58+
59+
makePath("../file1", paths);
60+
61+
//deno-lint-ignore require-await
62+
await assertRejects(async () => {
63+
// this should raise because it resolves outside of projectRoot.
64+
return makePath("../../file1", paths);
65+
});
66+
});

0 commit comments

Comments
 (0)