Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2619,7 +2619,6 @@
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9",
"@types/mocha": "^8.2.3",
"@types/node": "^22.20.5",
"@types/sinon": "^10.0.20",
Expand All @@ -2645,7 +2644,7 @@
"cheerio": "1.2.0",
"ejs": "^7.0.2",
"highlight.js": "^11.12.0",
"js-yaml": "^4.3.2",
"js-yaml": "^5.5.0",
"vscode-languageclient": "^10.1.2",
"winreg": "^1.2.5"
},
Expand Down
12 changes: 2 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 8 additions & 11 deletions src/rTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { config, delay, getRterm, getCurrentWorkspaceFolder, getRPathConfigEntry
import { resolveBackend, jgdEnabled, CommonPlotManager } from './plotViewer';
import { tryInteractiveExecution } from './interactive/executionTarget';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { loadRMarkdownYaml } from './rmarkdown/yaml';

export let rTerm: vscode.Terminal | undefined = undefined;
let rTermResource: vscode.Uri | undefined;
Expand Down Expand Up @@ -96,12 +96,6 @@ const terminalSends = new WeakMap<vscode.Terminal, Promise<void>>();
let lastParamsRmdPath: string | undefined;
let lastParamsRmdVersion: number | undefined;

const rExprType = new yaml.Type('!r', {
kind: 'scalar',
construct: (data: string) => ({ __rExpr: data }),
});
const RMARKDOWN_SCHEMA = yaml.DEFAULT_SCHEMA.extend([rExprType]);

function valueToR(val: unknown): string {
if (val === null || val === undefined) {
return 'NULL';
Expand All @@ -118,6 +112,12 @@ function valueToR(val: unknown): string {
if (typeof val === 'object' && val !== null && '__rExpr' in (val as Record<string, unknown>)) {
return (val as { __rExpr: string }).__rExpr;
}
if (val instanceof Set) {
// js-yaml 5 represents !!set with Set instead of an object of nulls.
return valueToR(
Object.fromEntries(Array.from(val as Set<unknown>, (key) => [String(key), null])),
);
}
if (Array.isArray(val)) {
return `c(${val.map(valueToR).join(', ')})`;
}
Expand Down Expand Up @@ -145,10 +145,7 @@ export function getRmdParamsCommand(document: vscode.TextDocument): string | und
lastParamsRmdPath = filePath;
lastParamsRmdVersion = document.version;
try {
const frontmatter = yaml.load(match[1], { schema: RMARKDOWN_SCHEMA }) as Record<
string,
unknown
>;
const frontmatter = loadRMarkdownYaml(match[1]) as Record<string, unknown>;
const params = frontmatter?.['params'] as Record<string, unknown> | undefined;
if (!params || typeof params !== 'object') {
return undefined;
Expand Down
4 changes: 2 additions & 2 deletions src/rmarkdown/knit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as util from '../util';
import * as vscode from 'vscode';
import * as fs from 'node:fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { loadRMarkdownYaml } from './yaml';

import { RMarkdownManager, KnitWorkingDirectory } from './manager';
import { runTextInTerm } from '../rTerminal';
Expand Down Expand Up @@ -125,7 +125,7 @@ export class RMarkdownKnitManager extends RMarkdownManager {
let paramObj: IYamlFrontmatter = {};
if (yamlText) {
try {
const parsed: unknown = yaml.load(yamlText);
const parsed: unknown = loadRMarkdownYaml(yamlText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
paramObj = parsed as IYamlFrontmatter;
}
Expand Down
31 changes: 31 additions & 0 deletions src/rmarkdown/yaml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as yaml from 'js-yaml';

const rExpressionTag = yaml.defineScalarTag('!r', {
resolve: (source: string) => ({ __rExpr: source }),
identify: () => false,
});

const rMarkdownIntegerTag = {
...yaml.intCoreTag,
// v4 also accepted binary integers and signed octal/hexadecimal integers
// without an explicit !!int tag. v5 restricts those to explicit tags.
resolve: (source: string, _isExplicit: boolean, tagName: string) =>
yaml.intCoreTag.resolve(source, true, tagName),
};

// Keep the extra tags supported by js-yaml 4's DEFAULT_SCHEMA (including
// timestamps and merge keys), with YAML 1.2 boolean and number resolution.
const rMarkdownSchema = yaml.YAML11_SCHEMA.withTags(
yaml.CORE_SCHEMA.tags,
rMarkdownIntegerTag,
rExpressionTag,
);

export function loadRMarkdownYaml(source: string): unknown {
// loadAll retains v4's undefined result for empty/comment-only headers.
const documents = yaml.loadAll(source, { schema: rMarkdownSchema });
if (documents.length > 1) {
throw new yaml.YAMLException('expected a single YAML frontmatter document');
}
return documents[0];
}
42 changes: 42 additions & 0 deletions src/test/suite/rmarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,46 @@ suite('R Markdown execution', () => {
);
});

test('knit frontmatter preserves merged commands alongside !r parameters', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-knit-yaml-'));
try {
const file = path.join(directory, 'test.Rmd');
const knit = 'function(input, ...) rmarkdown::render(input, ...)';
fs.writeFileSync(
file,
`---
defaults: &defaults
knit: ${knit}
site: rmarkdown::render_site
<<: *defaults
date: !!timestamp 2026-11-07
params:
count: !r 1+1
---\n`,
);
const manager = new RMarkdownKnitManager() as unknown as {
getYamlFrontmatter(file: string): Record<string, unknown>;
getKnitCommand(
params: Record<string, unknown>,
file: string,
format?: string,
): Promise<string>;
};
const frontmatter = manager.getYamlFrontmatter(file);
assert.strictEqual(frontmatter['knit'], knit);
assert.strictEqual(frontmatter['site'], 'rmarkdown::render_site');
assert.deepStrictEqual(frontmatter['params'], { count: { __rExpr: '1+1' } });
assert.deepStrictEqual(frontmatter['date'], new Date('2026-11-07T00:00:00Z'));
const literal = util.ToRStringLiteral(file, '"');
assert.strictEqual(
await manager.getKnitCommand(frontmatter, literal, 'html_document'),
`(${knit})(${literal}, output_format = "html_document")`,
);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});

test('non-object YAML frontmatter cannot leave knitting permanently busy', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-knit-'));
try {
Expand Down Expand Up @@ -178,7 +218,9 @@ suite('R Markdown execution', () => {
) => Promise<void>;
getYamlFrontmatter(file: string): unknown;
};
const parseError = sandbox.stub(console, 'error');
assert.deepStrictEqual(manager.getYamlFrontmatter(file), {});
sinon.assert.notCalled(parseError);
const render = sandbox
.stub(manager, 'renderDocument')
.rejects(new Error('transient failure'));
Expand Down
81 changes: 81 additions & 0 deletions src/test/suite/rmdParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,87 @@ suite('Rmd Params Test Suite', () => {
assert.strictEqual(cmd, 'params <- list(a = 1+1)');
});

test('getRmdParamsCommand preserves merged parameter defaults and nested !r values', () => {
const mockDoc = {
languageId: 'rmd',
getText: () => `---
defaults: &defaults
count: 2
label: default
params:
<<: *defaults
label: overridden
expression:
value: !r |
sum(c(1, 2))
values: [true, null, !r 1+1]
---`,
uri: vscode.Uri.file('/test_merged_params.Rmd'),
version: 1,
} as vscode.TextDocument;

assert.strictEqual(
rTerminal.getRmdParamsCommand(mockDoc),
'params <- list(count = 2, label = "overridden", expression = sum(c(1, 2))\n, values = c(TRUE, NULL, 1+1))',
);
});

test('getRmdParamsCommand keeps YAML 1.2 scalars with legacy metadata tags', () => {
const mockDoc = {
languageId: 'rmd',
getText: () => `---
date: !!timestamp 2026-11-07
params:
enabled: true
label: yes
mode: on
count: 012
binary: 0b101
hex: -0xF
nothing: null
---`,
uri: vscode.Uri.file('/test_scalar_params.Rmd'),
version: 1,
} as vscode.TextDocument;

assert.strictEqual(
rTerminal.getRmdParamsCommand(mockDoc),
'params <- list(enabled = TRUE, label = "yes", mode = "on", count = 12, binary = 5, hex = -15, nothing = NULL)',
);
});

test('getRmdParamsCommand preserves set parameters as named null values', () => {
const mockDoc = {
languageId: 'rmd',
getText: () => '---\nparams:\n choices: !!set {one: null, two: null}\n---',
uri: vscode.Uri.file('/test_set_params.Rmd'),
version: 1,
} as vscode.TextDocument;

assert.strictEqual(
rTerminal.getRmdParamsCommand(mockDoc),
'params <- list(choices = list(one = NULL, two = NULL))',
);
});

test('getRmdParamsCommand rejects malformed YAML and non-scalar !r tags', () => {
for (const [index, value] of [
'[1,',
'!unknown value',
'!r [1, 2]',
'!r {a: 1}',
].entries()) {
const mockDoc = {
languageId: 'rmd',
getText: () => `---\nparams:\n a: ${value}\n---`,
uri: vscode.Uri.file(`/test_invalid_params_${index}.Rmd`),
version: 1,
} as vscode.TextDocument;

assert.strictEqual(rTerminal.getRmdParamsCommand(mockDoc), undefined);
}
});

test('getRmdParamsCommand respects cache invalidation by version', () => {
const mockDoc = {
languageId: 'rmd',
Expand Down
Loading