Skip to content

Commit

Permalink
R markdown templates (#984)
Browse files Browse the repository at this point in the history
* Add templates.R

* Implement newDraft command

* Fix path

* Update draft

* Fix file path

* Remove unused eslint-disable

* Disable eslint no-explicit-any

* Handle create_dir

* Create untitled document if not create_dir

* Use spawn

* Update getTemplateItems

* Format file

* Update getTemplateItems

* Update confirm replacing folder
  • Loading branch information
renkun-ken authored Feb 13, 2022
1 parent 9b525aa commit f5d857e
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 6 deletions.
25 changes: 25 additions & 0 deletions R/rmarkdown/templates.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
requireNamespace("jsonlite")
requireNamespace("yaml")

pkgs <- .packages(all.available = TRUE)
templates <- new.env()
template_dirs <- lapply(pkgs, function(pkg) {
dir <- system.file("rmarkdown/templates", package = pkg)
if (dir.exists(dir)) {
ids <- list.dirs(dir, full.names = FALSE, recursive = FALSE)
for (id in ids) {
file <- file.path(dir, id, "template.yaml")
if (file.exists(file)) {
data <- yaml::read_yaml(file)
data$id <- id
data$package <- pkg
templates[[paste0(pkg, "::", id)]] <- data
}
}
}
})

template_list <- unname(as.list(templates))
lim <- Sys.getenv("VSCR_LIM")
json <- jsonlite::toJSON(template_list, auto_unbox = TRUE)
cat(lim, json, lim, sep = "\n", file = stdout())
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,11 @@
"category": "R",
"command": "r.goToNextChunk"
},
{
"title": "New Draft",
"category": "R Markdown",
"command": "r.rmarkdown.newDraft"
},
{
"command": "r.rmarkdown.setKnitDirectory",
"title": "R: Set Knit directory",
Expand Down
1 change: 1 addition & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp
'r.goToNextChunk': rmarkdown.goToNextChunk,
'r.runChunks': rTerminal.runChunksInTerm,

'r.rmarkdown.newDraft': () => rmarkdown.newDraft(),
'r.rmarkdown.setKnitDirectory': () => rmdKnitManager.setKnitDir(),
'r.rmarkdown.showPreviewToSide': () => rmdPreviewManager.previewRmd(vscode.ViewColumn.Beside),
'r.rmarkdown.showPreview': (uri: vscode.Uri) => rmdPreviewManager.previewRmd(vscode.ViewColumn.Active, uri),
Expand Down
146 changes: 146 additions & 0 deletions src/rmarkdown/draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { QuickPickItem, QuickPickOptions, Uri, window, workspace } from 'vscode';
import { extensionContext } from '../extension';
import { executeRCommand, getCurrentWorkspaceFolder, getRpath, ToRStringLiteral, spawnAsync, getConfirmation } from '../util';
import * as cp from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';

interface TemplateInfo {
id: string;
package: string;
name: string;
description: string;
create_dir: boolean;
}

interface TemplateItem extends QuickPickItem {
info: TemplateInfo;
}

async function getTemplateItems(cwd: string): Promise<TemplateItem[]> {
const lim = '---vsc---';
const rPath = await getRpath();
const options: cp.CommonOptions = {
cwd: cwd,
env: {
...process.env,
VSCR_LIM: lim
}
};

const rScriptFile = extensionContext.asAbsolutePath('R/rmarkdown/templates.R');
const args = [
'--silent',
'--slave',
'--no-save',
'--no-restore',
'-f',
rScriptFile
];

try {
const result = await spawnAsync(rPath, args, options);
if (result.status !== 0) {
throw result.error || new Error(result.stderr);
}
const re = new RegExp(`${lim}(.*)${lim}`, 'ms');
const match = re.exec(result.stdout);
if (match.length !== 2) {
throw new Error('Could not parse R output.');
}
const json = match[1];
const templates = <TemplateInfo[]>JSON.parse(json) || [];
const items = templates.map((x) => {
return {
alwaysShow: false,
description: `{${x.package}}`,
label: x.name + (x.create_dir ? ' $(new-folder)' : ''),
detail: x.description,
picked: false,
info: x
};
});
return items;
} catch (e) {
console.log(e);
void window.showErrorMessage((<{ message: string }>e).message);
}
}

async function launchTemplatePicker(cwd: string): Promise<TemplateItem> {
const options: QuickPickOptions = {
matchOnDescription: true,
matchOnDetail: true,
canPickMany: false,
ignoreFocusOut: false,
placeHolder: '',
onDidSelectItem: undefined
};

const items = await getTemplateItems(cwd);

const selection: TemplateItem = await window.showQuickPick<TemplateItem>(items, options);
return selection;
}

async function makeDraft(file: string, template: TemplateItem, cwd: string): Promise<string> {
const fileString = ToRStringLiteral(file, '');
const cmd = `cat(normalizePath(rmarkdown::draft(file='${fileString}', template='${template.info.id}', package='${template.info.package}', edit=FALSE)))`;
return await executeRCommand(cmd, cwd, (e: Error) => {
void window.showErrorMessage(e.message);
return '';
});
}

export async function newDraft(): Promise<void> {
const cwd = getCurrentWorkspaceFolder()?.uri.fsPath ?? os.homedir();
const template = await launchTemplatePicker(cwd);
if (!template) {
return;
}

if (template.info.create_dir) {
let defaultPath = path.join(cwd, 'draft');
let i = 1;
while (fs.existsSync(defaultPath)) {
defaultPath = path.join(cwd, `draft_${++i}`);
}
const uri = await window.showSaveDialog({
defaultUri: Uri.file(defaultPath),
filters: {
'Folder': ['']
},
saveLabel: 'Create Folder',
title: 'R Markdown: New Draft'
});

if (uri) {
const parsedPath = path.parse(uri.fsPath);
const dir = path.join(parsedPath.dir, parsedPath.name);
if (fs.existsSync(dir)) {
if (await getConfirmation(`Folder already exists. Are you sure to replace the folder?`)) {
fs.rmdirSync(dir, { recursive: true });
} else {
return;
}
}

const draftPath = await makeDraft(uri.fsPath, template, cwd);
if (draftPath) {
await workspace.openTextDocument(draftPath)
.then(document => window.showTextDocument(document));
}
}
} else {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-R-'));
const tempFile = path.join(tempDir, 'draft.Rmd');
const draftPath = await makeDraft(tempFile, template, cwd);
if (draftPath) {
const text = fs.readFileSync(draftPath, 'utf8');
await workspace.openTextDocument({ language: 'rmd', content: text })
.then(document => window.showTextDocument(document));
}
fs.rmdirSync(tempDir, { recursive: true });
}
}
1 change: 1 addition & 0 deletions src/rmarkdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { config } from '../util';
// reexports
export { knitDir, RMarkdownKnitManager } from './knit';
export { RMarkdownPreviewManager } from './preview';
export { newDraft } from './draft';

function isRDocument(document: vscode.TextDocument) {
return (document.languageId === 'r');
Expand Down
12 changes: 6 additions & 6 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export async function doWithProgress<T>(cb: (token?: vscode.CancellationToken, p
export async function getCranUrl(path: string = '', cwd?: string): Promise<string> {
const defaultCranUrl = 'https://cran.r-project.org/';
// get cran URL from R. Returns empty string if option is not set.
const baseUrl = await executeRCommand('cat(getOption(\'repos\')[\'CRAN\'])', undefined, cwd);
const baseUrl = await executeRCommand('cat(getOption(\'repos\')[\'CRAN\'])', cwd);
let url: string;
try {
url = new URL(path, baseUrl).toString();
Expand All @@ -301,12 +301,12 @@ export async function getCranUrl(path: string = '', cwd?: string): Promise<strin

// executes an R command returns its output to stdout
// uses a regex to filter out output generated e.g. by code in .Rprofile
// returns the provided fallBack when the command failes
// returns the provided fallback when the command failes
//
// WARNING: Cannot handle double quotes in the R command! (e.g. `print("hello world")`)
// Single quotes are ok.
//
export async function executeRCommand(rCommand: string, fallBack?: string, cwd?: string): Promise<string | undefined> {
export async function executeRCommand(rCommand: string, cwd?: string, fallback?: string | ((e: Error) => string)): Promise<string | undefined> {
const rPath = await getRpath();

const options: cp.CommonOptions = {
Expand Down Expand Up @@ -338,8 +338,8 @@ export async function executeRCommand(rCommand: string, fallBack?: string, cwd?:
}
ret = match[1];
} catch (e) {
if (fallBack) {
ret = fallBack;
if (fallback) {
ret = (typeof fallback === 'function' ? fallback(e) : fallback);
} else {
console.warn(e);
}
Expand Down Expand Up @@ -497,7 +497,7 @@ export async function spawnAsync(command: string, args?: ReadonlyArray<string>,
*/
export async function isRPkgIntalled(name: string, cwd: string, promptToInstall: boolean = false, installMsg?: string, postInstallMsg?: string): Promise<boolean> {
const cmd = `cat(requireNamespace('${name}', quietly=TRUE))`;
const rOut = await executeRCommand(cmd, 'FALSE', cwd);
const rOut = await executeRCommand(cmd, cwd, 'FALSE');
const isInstalled = rOut === 'TRUE';
if (promptToInstall && !isInstalled) {
if (installMsg === undefined) {
Expand Down

0 comments on commit f5d857e

Please sign in to comment.