diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index f5bacbd9..88365b0b 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,6 +2,9 @@ ## 1.130.0 (Unreleased) +- Added clickable document links for file paths in `_quarto.yml` files. File paths are now clickable and navigate directly to the referenced file (). +- Added filepath autocompletion in `_quarto.yml` files. When editing YAML values, the extension now suggests project files as you type (). + ## 1.129.0 (Release on 2026-01-29) - Fixed Copilot completions in `.qmd` documents (). diff --git a/apps/vscode/package.json b/apps/vscode/package.json index e1a9b804..42dd4d48 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1258,6 +1258,20 @@ "default": false, "markdownDescription": "Write markdown links as references rather than inline. Reference links are written to the location specified in `#quarto.visualEditor.markdownReferences#`." }, + "quarto.yaml.documentLinks.enabled": { + "order": 45, + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "Enable clickable file links in `_quarto.yml` files." + }, + "quarto.yaml.filepathCompletions.enabled": { + "order": 46, + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "Enable file path autocomplete in `_quarto.yml` files." + }, "quarto.zotero.library": { "order": 50, "scope": "window", diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 7f170f5e..bd58266f 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -42,6 +42,8 @@ import { activateDiagram } from "./providers/diagram/diagram"; import { activateCodeFormatting } from "./providers/format"; import { activateOptionEnterProvider } from "./providers/option"; import { activateBackgroundHighlighter } from "./providers/background"; +import { activateYamlLinks } from "./providers/yaml-links"; +import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions"; import { activateContextKeySetter } from "./providers/context-keys"; import { CommandManager } from "./core/command"; import { createQuartoExtensionApi, QuartoExtensionApi } from "./api"; @@ -168,6 +170,12 @@ export async function activate(context: vscode.ExtensionContext): Promise("yaml.filepathCompletions.enabled", true)) { + context.subscriptions.push( + languages.registerCompletionItemProvider( + { scheme: "file", pattern: "**/_quarto.{yml,yaml}" }, + new QuartoYamlFilepathCompletionProvider(), + "/", "." // Trigger on path separators + ) + ); + } +} + +class QuartoYamlFilepathCompletionProvider implements CompletionItemProvider { + async provideCompletionItems( + document: TextDocument, + position: Position, + _token: CancellationToken, + _context: CompletionContext + ): Promise { + const line = document.lineAt(position).text; + const linePrefix = line.substring(0, position.character); + + // Only provide completions after ': ' or '- ' + if (!this.shouldProvideCompletions(linePrefix)) { + return []; + } + + const documentDir = path.dirname(document.uri.fsPath); + const projectFiles = await getProjectFiles(documentDir); + + // Get current input to filter completions + const currentInput = this.getCurrentInput(linePrefix); + + return projectFiles + .filter(file => file.toLowerCase().includes(currentInput.toLowerCase())) + .map(file => { + const item = new CompletionItem(file, CompletionItemKind.File); + item.detail = "Quarto project file"; + item.insertText = file; + + // If there's existing input, replace it + if (currentInput) { + const startPos = position.character - currentInput.length; + item.range = new Range( + new Position(position.line, startPos), + position + ); + } + + return item; + }); + } + + private shouldProvideCompletions(linePrefix: string): boolean { + // Check if we're in a position where file path completions make sense + // After ': ' for key-value pairs or after '- ' for list items + return /(?::\s+|^\s*-\s+)\S*$/.test(linePrefix); + } + + private getCurrentInput(linePrefix: string): string { + // Extract the current partial input after ': ' or '- ' + const match = linePrefix.match(/(?::\s+|^\s*-\s+)(\S*)$/); + return match ? match[1] : ""; + } +} + +async function getProjectFiles(projectDir: string): Promise { + const extensionPattern = `**/*.{${FILE_EXTENSIONS.join(',')}}`; + + try { + const files = await glob(extensionPattern, { + cwd: projectDir, + ignore: IGNORE_PATTERNS.map(p => `**/${p}/**`), + nodir: true, + }); + + return files.sort(); + } catch { + return []; + } +} diff --git a/apps/vscode/src/providers/yaml-links.ts b/apps/vscode/src/providers/yaml-links.ts new file mode 100644 index 00000000..89a85f4f --- /dev/null +++ b/apps/vscode/src/providers/yaml-links.ts @@ -0,0 +1,127 @@ +/* + * yaml-links.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +/** + * Document Link Provider for Quarto YAML Configuration Files + * + * This module provides clickable document links for file paths referenced in + * `_quarto.yml` and `_quarto.yaml` configuration files. When a file path is + * detected in the YAML content (e.g., `render: hello.qmd` or `css: styles.css`), + * it becomes a clickable link that navigates directly to that file. + * + * The provider: + * - Scans YAML content for file paths after `: ` (key-value) or `- ` (list items) + * - Verifies that referenced files exist before creating links + * - Resolves relative paths from the location of the _quarto.yml file + * + * Can be disabled via the `quarto.yaml.documentLinks.enabled` setting. + */ + +import path from "node:path"; +import fs from "node:fs"; + +import { + CancellationToken, + DocumentLink, + DocumentLinkProvider, + ExtensionContext, + languages, + Position, + Range, + TextDocument, + Uri, + workspace, +} from "vscode"; + +const FILE_EXTENSIONS = ['qmd', 'scss', 'css', 'html', 'js', 'bib', 'tex', 'md']; +const IGNORE_PATTERNS = ['.git', 'node_modules', '_site', '_freeze', '.quarto']; + +export function activateYamlLinks(context: ExtensionContext) { + const config = workspace.getConfiguration("quarto"); + + if (config.get("yaml.documentLinks.enabled", true)) { + context.subscriptions.push( + languages.registerDocumentLinkProvider( + { scheme: "file", pattern: "**/_quarto.{yml,yaml}" }, + new QuartoYamlLinkProvider() + ) + ); + } +} + +class QuartoYamlLinkProvider implements DocumentLinkProvider { + provideDocumentLinks( + document: TextDocument, + _token: CancellationToken + ): DocumentLink[] { + const links: DocumentLink[] = []; + const text = document.getText(); + const lines = text.split('\n'); + const documentDir = path.dirname(document.uri.fsPath); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const lineLinks = this.findLinksInLine(line, lineIndex, documentDir); + links.push(...lineLinks); + } + + return links; + } + + private findLinksInLine( + line: string, + lineIndex: number, + documentDir: string + ): DocumentLink[] { + const links: DocumentLink[] = []; + + // Match file paths in YAML values + // Pattern: looks for paths after ': ' or '- ' that end with known extensions + const extensionPattern = FILE_EXTENSIONS.join('|'); + const regex = new RegExp( + `(?:^\\s*-\\s*|:\\s*)([\\w./-]+\\.(?:${extensionPattern}))`, + 'gi' + ); + + let match; + while ((match = regex.exec(line)) !== null) { + const filePath = match[1]; + const startIndex = match.index + match[0].length - filePath.length; + const endIndex = startIndex + filePath.length; + + // Resolve the full path + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.resolve(documentDir, filePath); + + // Check if the file exists + if (fs.existsSync(fullPath)) { + const range = new Range( + new Position(lineIndex, startIndex), + new Position(lineIndex, endIndex) + ); + + const link = new DocumentLink( + range, + Uri.file(fullPath) + ); + link.tooltip = `Open ${filePath}`; + links.push(link); + } + } + + return links; + } +} diff --git a/apps/vscode/src/test/examples/_quarto.yml b/apps/vscode/src/test/examples/_quarto.yml new file mode 100644 index 00000000..3a25ab2c --- /dev/null +++ b/apps/vscode/src/test/examples/_quarto.yml @@ -0,0 +1,15 @@ +project: + type: default + +metadata: + title: "Test Project" + +render: + - hello.qmd + - valid-basics.qmd + +bibliography: references.bib + +format: + html: + css: styles.css diff --git a/apps/vscode/src/test/yamlProviders.test.ts b/apps/vscode/src/test/yamlProviders.test.ts new file mode 100644 index 00000000..5a35be5a --- /dev/null +++ b/apps/vscode/src/test/yamlProviders.test.ts @@ -0,0 +1,129 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import { WORKSPACE_PATH, examplesOutUri } from "./test-utils"; + +suite("YAML Providers", function () { + suiteSetup(async function () { + await vscode.workspace.fs.delete(examplesOutUri(), { recursive: true }); + await vscode.workspace.fs.copy(vscode.Uri.file(WORKSPACE_PATH), examplesOutUri()); + }); + + suite("Document Links", function () { + test("Provides document links for existing files in _quarto.yml", async function () { + const quartoYmlUri = examplesOutUri("_quarto.yml"); + const doc = await vscode.workspace.openTextDocument(quartoYmlUri); + + const links = await vscode.commands.executeCommand( + "vscode.executeLinkProvider", + doc.uri + ); + + assert.ok(links, "Should return document links"); + assert.ok(links.length >= 3, `Expected at least 3 links, found ${links.length}`); + + const helloLink = links.find( + (link) => link.target?.fsPath.endsWith("hello.qmd") + ); + assert.ok(helloLink, "Should have a link for hello.qmd"); + + const validBasicsLink = links.find( + (link) => link.target?.fsPath.endsWith("valid-basics.qmd") + ); + assert.ok(validBasicsLink, "Should have a link for valid-basics.qmd"); + + const bibLink = links.find( + (link) => link.target?.fsPath.endsWith("references.bib") + ); + assert.ok(bibLink, "Should have a link for references.bib"); + }); + + test("Does not provide links for non-existent files", async function () { + const quartoYmlUri = examplesOutUri("_quarto.yml"); + const doc = await vscode.workspace.openTextDocument(quartoYmlUri); + + const links = await vscode.commands.executeCommand( + "vscode.executeLinkProvider", + doc.uri + ); + + const stylesLink = links?.find( + (link) => link.target?.fsPath.endsWith("styles.css") + ); + assert.ok(!stylesLink, "Should not have a link for non-existent styles.css"); + }); + }); + + suite("Filepath Completions", function () { + test("Provides file path completions in _quarto.yml", async function () { + const quartoYmlUri = examplesOutUri("_quarto.yml"); + const doc = await vscode.workspace.openTextDocument(quartoYmlUri); + await vscode.window.showTextDocument(doc); + + const position = new vscode.Position(7, 4); + const completions = await vscode.commands.executeCommand( + "vscode.executeCompletionItemProvider", + doc.uri, + position + ); + + assert.ok(completions, "Should return completions"); + assert.ok(completions.items.length > 0, "Should have completion items"); + + const qmdCompletions = completions.items.filter( + (item) => item.label.toString().endsWith(".qmd") + ); + assert.ok(qmdCompletions.length > 0, "Should suggest .qmd files"); + }); + + test("Completion replaces partial input with dot correctly", async function () { + const testDir = examplesOutUri("test-completion"); + const tempYmlUri = vscode.Uri.joinPath(testDir, "_quarto.yml"); + + const content = `project: + type: default + +render: + - hello.q`; + + await vscode.workspace.fs.createDirectory(testDir); + await vscode.workspace.fs.copy( + examplesOutUri("hello.qmd"), + vscode.Uri.joinPath(testDir, "hello.qmd") + ); + await vscode.workspace.fs.writeFile(tempYmlUri, Buffer.from(content, "utf-8")); + + try { + const doc = await vscode.workspace.openTextDocument(tempYmlUri); + await vscode.window.showTextDocument(doc); + + const position = new vscode.Position(4, 11); + const completions = await vscode.commands.executeCommand( + "vscode.executeCompletionItemProvider", + doc.uri, + position + ); + + assert.ok(completions, "Should return completions"); + + const helloCompletion = completions.items.find( + (item) => item.label.toString() === "hello.qmd" + ); + assert.ok(helloCompletion, "Should have hello.qmd completion for partial 'hello.q'"); + + const range = helloCompletion.range; + assert.ok(range, "Completion should have a range"); + + if (range instanceof vscode.Range) { + assert.strictEqual(range.start.character, 4, "Range should start at column 4 (after ' - ')"); + assert.strictEqual(range.end.character, 11, "Range should end at column 11"); + } else if (range && typeof range === "object" && "replacing" in range) { + const replacingRange = (range as { replacing: vscode.Range }).replacing; + assert.strictEqual(replacingRange.start.character, 4, "Replacing range should start at column 4 to replace 'hello.q'"); + assert.strictEqual(replacingRange.end.character, 11, "Replacing range should end at column 11"); + } + } finally { + await vscode.workspace.fs.delete(testDir, { recursive: true }); + } + }); + }); +});