Skip to content

Commit 1b82bf3

Browse files
authored
Merge pull request #12419 from quarto-dev/bugfix/11967
do not allow !expr tags outside of knitr execution
2 parents 97ee0ae + d89ecf5 commit 1b82bf3

File tree

6 files changed

+132
-18
lines changed

6 files changed

+132
-18
lines changed

news/changelog-1.7.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ All changes included in 1.7:
139139
- ([#11606](https://github.com/quarto-dev/quarto-cli/discussions/11606)): Added a new `QUARTO_DOCUMENT_FILE` env var available to computation engine to the name of the file currently being rendered.
140140
- ([#11803](https://github.com/quarto-dev/quarto-cli/pull/11803)): Added a new CLI command `quarto call`. First users of this interface are the new `quarto call engine julia ...` subcommands.
141141
- ([#11951](https://github.com/quarto-dev/quarto-cli/issues/11951)): Raw LaTeX table without `tbl-` prefix label for using Quarto crossref are now correctly passed through unmodified.
142+
- ([#11967](https://github.com/quarto-dev/quarto-cli/issues/11967)): Produce a better error message when YAML metadata with `!expr` tags are used outside of `knitr` code cells.
142143
- ([#12117](https://github.com/quarto-dev/quarto-cli/issues/12117)): Color output to stdout and stderr is now correctly rendered for `html` format in the Jupyter and Julia engines.
143144
- ([#12264](https://github.com/quarto-dev/quarto-cli/issues/12264)): Upgrade `dart-sass` to 1.85.1.
144145
- ([#11803](https://github.com/quarto-dev/quarto-cli/pull/11803)): Added a new CLI command `quarto call`. First users of this interface are the new `quarto call engine julia ...` subcommands.

src/core/lib/yaml-validation/validator.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,69 @@ import {
3030

3131
import { resolveSchema } from "./resolve.ts";
3232

33-
import { MappedString } from "../text-types.ts";
34-
import { createLocalizedError } from "./errors.ts";
33+
import { MappedString, StringMapResult } from "../text-types.ts";
34+
import { createLocalizedError, createSourceContext } from "./errors.ts";
3535
import { InternalError } from "../error.ts";
36+
import { mappedIndexToLineCol } from "../mapped-text.ts";
37+
import { TidyverseError } from "../errors-types.ts";
3638

3739
////////////////////////////////////////////////////////////////////////////////
3840

41+
function createNiceError(obj: {
42+
violatingObject: AnnotatedParse;
43+
source: MappedString;
44+
message: string;
45+
}): TidyverseError {
46+
const {
47+
violatingObject,
48+
source,
49+
message,
50+
} = obj;
51+
const locF = mappedIndexToLineCol(source);
52+
53+
let location;
54+
try {
55+
location = {
56+
start: locF(violatingObject.start),
57+
end: locF(violatingObject.end),
58+
};
59+
} catch (_e) {
60+
location = {
61+
start: { line: 0, column: 0 },
62+
end: { line: 0, column: 0 },
63+
};
64+
}
65+
66+
const mapResult = source.map(violatingObject.start);
67+
const fileName = mapResult ? mapResult.originalString.fileName : undefined;
68+
return {
69+
heading: message,
70+
error: [],
71+
info: {},
72+
fileName,
73+
location: location!,
74+
sourceContext: createSourceContext(violatingObject.source, {
75+
start: violatingObject.start,
76+
end: violatingObject.end,
77+
}),
78+
};
79+
}
80+
81+
export class NoExprTag extends Error {
82+
constructor(violatingObject: AnnotatedParse, source: MappedString) {
83+
super(`Unexpected !expr tag`);
84+
this.name = "NoExprTag";
85+
this.niceError = createNiceError({
86+
violatingObject,
87+
source,
88+
message:
89+
"!expr tags are not allowed in Quarto outside of knitr code cells.",
90+
});
91+
}
92+
93+
niceError: TidyverseError;
94+
}
95+
3996
class ValidationContext {
4097
instancePath: (number | string)[];
4198
root: ValidationTraceNode;
@@ -561,6 +618,12 @@ function validateObject(
561618
}
562619
}
563620
}
621+
if (
622+
value.result && typeof value.result === "object" &&
623+
!Array.isArray(value.result) && value.result.tag === "!expr"
624+
) {
625+
throw new NoExprTag(value, value.source);
626+
}
564627
throw new InternalError(`Couldn't locate key ${key}`);
565628
};
566629
const inspectedProps: Set<string> = new Set();

src/core/schema/validate-document.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { isObject } from "../lodash.ts";
2121
import { getFrontMatterSchema } from "../lib/yaml-schema/front-matter.ts";
2222
import { JSONValue, LocalizedError } from "../lib/yaml-schema/types.ts";
2323
import { MappedString } from "../lib/mapped-text.ts";
24+
import { NoExprTag } from "../lib/yaml-validation/validator.ts";
2425

2526
export async function validateDocumentFromSource(
2627
src: MappedString,
@@ -77,22 +78,31 @@ export async function validateDocumentFromSource(
7778
) {
7879
const frontMatterSchema = await getFrontMatterSchema();
7980

80-
await withValidator(frontMatterSchema, async (frontMatterValidator) => {
81-
const fmValidation = await frontMatterValidator.validateParseWithErrors(
82-
frontMatterText,
83-
annotation,
84-
"Validation of YAML front matter failed.",
85-
errorFn,
86-
reportOnce(
87-
(err: TidyverseError) =>
88-
error(tidyverseFormatError(err), { colorize: false }),
89-
reportSet,
90-
),
91-
);
92-
if (fmValidation && fmValidation.errors.length) {
93-
result.push(...fmValidation.errors);
81+
try {
82+
await withValidator(frontMatterSchema, async (frontMatterValidator) => {
83+
const fmValidation = await frontMatterValidator
84+
.validateParseWithErrors(
85+
frontMatterText,
86+
annotation,
87+
"Validation of YAML front matter failed.",
88+
errorFn,
89+
reportOnce(
90+
(err: TidyverseError) =>
91+
error(tidyverseFormatError(err), { colorize: false }),
92+
reportSet,
93+
),
94+
);
95+
if (fmValidation && fmValidation.errors.length) {
96+
result.push(...fmValidation.errors);
97+
}
98+
});
99+
} catch (e) {
100+
if (e.name === "NoExprTag") {
101+
const err = e as NoExprTag;
102+
error(tidyverseFormatError(err.niceError), { colorize: false });
103+
throw e;
94104
}
95-
});
105+
}
96106
}
97107
} else {
98108
firstContentCellIndex = 0;

tests/docs/yaml/issue-11967.qmd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: "Some title"
3+
format: html
4+
brand: !expr 'c("demo/_brand.yml")'
5+
---

tests/smoke/smoke-all.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ async function guessFormat(fileName: string): Promise<string[]> {
5555
for (const cell of cells) {
5656
if (cell.cell_type === "raw") {
5757
const src = cell.source.value.replaceAll(/^---$/mg, "");
58-
const yaml = parse(src);
58+
let yaml;
59+
try {
60+
yaml = parse(src);
61+
} catch (e) {
62+
if (e.message.includes("unknown tag")) {
63+
// assume it's not necessary to guess the format
64+
continue;
65+
}
66+
}
5967
if (yaml && typeof yaml === "object") {
6068
// deno-lint-ignore no-explicit-any
6169
const format = (yaml as Record<string, any>).format;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* issue-11967.test.ts
3+
*
4+
* Copyright (C) 2025 Posit Software, PBC
5+
*
6+
*/
7+
8+
import { testQuartoCmd } from "../../test.ts";
9+
import { fileLoader } from "../../utils.ts";
10+
import { printsMessage } from "../../verify.ts";
11+
12+
const yamlDocs = fileLoader("yaml");
13+
14+
const testYamlValidationFails = (file: string) => {
15+
testQuartoCmd(
16+
"render",
17+
[yamlDocs(file, "html").input, "--to", "html", "--quiet"],
18+
[printsMessage({level: "ERROR", regex: /\!expr tags are not allowed in Quarto outside of knitr code cells/})],
19+
);
20+
};
21+
22+
const files = [
23+
"issue-11967.qmd",
24+
];
25+
26+
files.forEach(testYamlValidationFails);
27+

0 commit comments

Comments
 (0)