diff --git a/apps/lsp/src/config.ts b/apps/lsp/src/config.ts index e34cf99b..f22d826e 100644 --- a/apps/lsp/src/config.ts +++ b/apps/lsp/src/config.ts @@ -43,6 +43,9 @@ export interface Settings { readonly scale: number; readonly extensions: MathjaxSupportedExtension[]; } + readonly symbols: { + readonly exportToWorkspace: 'default' | 'all' | 'none'; + }; }; readonly markdown: { readonly preferredMdPathExtensionStyle: 'auto' | 'includeExtension' | 'removeExtension'; @@ -88,6 +91,9 @@ function defaultSettings(): Settings { mathjax: { scale: 1, extensions: [] + }, + symbols: { + exportToWorkspace: 'all' } }, markdown: { @@ -165,6 +171,9 @@ export class ConfigurationManager extends Disposable { mathjax: { scale: settings.quarto.mathjax.scale, extensions: settings.quarto.mathjax.extensions + }, + symbols: { + exportToWorkspace: settings.quarto.symbols.exportToWorkspace } } }; @@ -224,12 +233,13 @@ export function lsConfiguration(configManager: ConfigurationManager): LsConfigur }, get mathjaxExtensions(): readonly MathjaxSupportedExtension[] { return configManager.getSettings().quarto.mathjax.extensions; + }, + get exportSymbolsToWorkspace(): 'default' | 'all' | 'none' { + return configManager.getSettings().quarto.symbols.exportToWorkspace; } } } - - export function getDiagnosticsOptions(configManager: ConfigurationManager): DiagnosticOptions { const settings = configManager.getSettings(); if (!settings) { diff --git a/apps/lsp/src/r-utils.ts b/apps/lsp/src/r-utils.ts new file mode 100644 index 00000000..3845186a --- /dev/null +++ b/apps/lsp/src/r-utils.ts @@ -0,0 +1,27 @@ +/* + * r-utils.ts + * + * Copyright (C) 2025 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. + * + */ + +import { isRPackage as isRPackageImpl } from "@utils/r-utils"; +import { IWorkspace } from './service'; + +// Version that selects workspace folder +export async function isRPackage(workspace: IWorkspace): Promise { + if (workspace.workspaceFolders === undefined) { + return false; + } + + const folderUri = workspace.workspaceFolders[0]; + return isRPackageImpl(folderUri); +} diff --git a/apps/lsp/src/service/config.ts b/apps/lsp/src/service/config.ts index f0b88031..14404470 100644 --- a/apps/lsp/src/service/config.ts +++ b/apps/lsp/src/service/config.ts @@ -79,6 +79,7 @@ export interface LsConfiguration { readonly colorTheme: 'light' | 'dark'; readonly mathjaxScale: number; readonly mathjaxExtensions: readonly MathjaxSupportedExtension[]; + readonly exportSymbolsToWorkspace: 'default' | 'all' | 'none'; } export const defaultMarkdownFileExtension = 'qmd'; @@ -109,7 +110,8 @@ const defaultConfig: LsConfiguration = { includeWorkspaceHeaderCompletions: 'never', colorTheme: 'light', mathjaxScale: 1, - mathjaxExtensions: [] + mathjaxExtensions: [], + exportSymbolsToWorkspace: 'all' }; export function defaultLsConfiguration(): LsConfiguration { diff --git a/apps/lsp/src/service/index.ts b/apps/lsp/src/service/index.ts index d9520f6f..b0d47059 100644 --- a/apps/lsp/src/service/index.ts +++ b/apps/lsp/src/service/index.ts @@ -209,7 +209,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL const diagnosticOnSaveComputer = new DiagnosticOnSaveComputer(init.quarto); const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger); const docSymbolProvider = new MdDocumentSymbolProvider(tocProvider, linkProvider, logger); - const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, docSymbolProvider); + const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, init.config, docSymbolProvider); const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider); return Object.freeze({ diff --git a/apps/lsp/src/service/providers/workspace-symbols.ts b/apps/lsp/src/service/providers/workspace-symbols.ts index b005c686..39c0e66d 100644 --- a/apps/lsp/src/service/providers/workspace-symbols.ts +++ b/apps/lsp/src/service/providers/workspace-symbols.ts @@ -21,17 +21,24 @@ import { Document } from 'quarto-core'; import { IWorkspace } from '../workspace'; import { MdWorkspaceInfoCache } from '../workspace-cache'; import { MdDocumentSymbolProvider } from './document-symbols'; +import { LsConfiguration } from '../config'; +import { isRPackage } from '../../r-utils'; export class MdWorkspaceSymbolProvider extends Disposable { - + readonly #config: LsConfiguration; readonly #cache: MdWorkspaceInfoCache; readonly #symbolProvider: MdDocumentSymbolProvider; + readonly #workspace: IWorkspace; constructor( workspace: IWorkspace, + config: LsConfiguration, symbolProvider: MdDocumentSymbolProvider, ) { super(); + + this.#workspace = workspace; + this.#config = config; this.#symbolProvider = symbolProvider; this.#cache = this._register(new MdWorkspaceInfoCache(workspace, (doc, token) => this.provideDocumentSymbolInformation(doc, token))); @@ -42,6 +49,12 @@ export class MdWorkspaceSymbolProvider extends Disposable { return []; } + switch (this.#config.exportSymbolsToWorkspace) { + case 'all': break; + case 'default': if (await shouldExportSymbolsToWorkspace(this.#workspace)) return []; else break; + case 'none': return []; + } + const allSymbols = await this.#cache.values(); if (token.isCancellationRequested) { @@ -73,3 +86,7 @@ export class MdWorkspaceSymbolProvider extends Disposable { } } } + +async function shouldExportSymbolsToWorkspace(workspace: IWorkspace): Promise { + return await isRPackage(workspace); +} diff --git a/apps/lsp/tsconfig.json b/apps/lsp/tsconfig.json index c283e723..18511316 100644 --- a/apps/lsp/tsconfig.json +++ b/apps/lsp/tsconfig.json @@ -3,9 +3,11 @@ "lib": ["ES2020"], "module": "CommonJS", "outDir": "./dist", - "rootDir": "./src", "sourceMap": true, "resolveJsonModule": true, + "paths": { + "@utils/*": ["../utils/*"] + } }, "exclude": ["node_modules"], "extends": "tsconfig/base.json", diff --git a/apps/utils/r-utils.ts b/apps/utils/r-utils.ts new file mode 100644 index 00000000..c22db0e7 --- /dev/null +++ b/apps/utils/r-utils.ts @@ -0,0 +1,63 @@ +/* + * r-utils.ts + * + * Copyright (C) 2025 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. + * + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import { URI } from 'vscode-uri'; + +/** + * Checks if the given folder contains an R package. + * + * Determined by: + * - Presence of a `DESCRIPTION` file. + * - Presence of `Package:` field. + * - Presence of `Type: package` field and value. + * + * The fields are checked to disambiguate real packages from book repositories using a `DESCRIPTION` file. + * + * @param folderPath Folder to check for a `DESCRIPTION` file. + */ +export async function isRPackage(folderUri: URI): Promise { + // We don't currently support non-file schemes + if (folderUri.scheme !== 'file') { + return false; + } + + const descriptionLines = await parseRPackageDescription(folderUri.fsPath); + if (!descriptionLines) { + return false; + } + + const packageLines = descriptionLines.filter(line => line.startsWith('Package:')); + const typeLines = descriptionLines.filter(line => line.startsWith('Type:')); + + const typeIsPackage = (typeLines.length > 0 + ? typeLines[0].toLowerCase().includes('package') + : false); + const typeIsPackageOrMissing = typeLines.length === 0 || typeIsPackage; + + return packageLines.length > 0 && typeIsPackageOrMissing; +} + +async function parseRPackageDescription(folderPath: string): Promise { + const filePath = path.join(folderPath, 'DESCRIPTION'); + + try { + const descriptionText = await fs.readFile(filePath, 'utf8'); + return descriptionText.split(/\r?\n/); + } catch { + return ['']; + } +} diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 1a69d34e..7b996342 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1319,6 +1319,17 @@ "error" ], "markdownDescription": "Log level for the Quarto language server." + }, + "quarto.symbols.exportToWorkspace": { + "type": "string", + "enum": ["default", "all", "none"], + "enumDescriptions": [ + "Depends on the project type: `\"none\"` in R packages, `\"all\"` otherwise.", + "", + "" + ], + "default": "default", + "description": "Whether Markdown elements like section headers are included in workspace symbol search." } } }, diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index f70cccd7..0de50175 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -13,6 +13,7 @@ * */ +import * as fs from "fs"; import * as path from "path"; import { ExtensionContext, diff --git a/apps/vscode/src/providers/preview/preview-util.ts b/apps/vscode/src/providers/preview/preview-util.ts index 9fff0edf..c1b43b79 100644 --- a/apps/vscode/src/providers/preview/preview-util.ts +++ b/apps/vscode/src/providers/preview/preview-util.ts @@ -60,34 +60,6 @@ export function isQuartoShinyKnitrDoc( } -export async function isRPackage(): Promise { - const descriptionLines = await parseRPackageDescription(); - if (!descriptionLines) { - return false; - } - const packageLines = descriptionLines.filter(line => line.startsWith('Package:')); - const typeLines = descriptionLines.filter(line => line.startsWith('Type:')); - const typeIsPackage = (typeLines.length > 0 - ? typeLines[0].toLowerCase().includes('package') - : false); - const typeIsPackageOrMissing = typeLines.length === 0 || typeIsPackage; - return packageLines.length > 0 && typeIsPackageOrMissing; -} - -async function parseRPackageDescription(): Promise { - if (vscode.workspace.workspaceFolders !== undefined) { - const folderUri = vscode.workspace.workspaceFolders[0].uri; - const fileUri = vscode.Uri.joinPath(folderUri, 'DESCRIPTION'); - try { - const bytes = await vscode.workspace.fs.readFile(fileUri); - const descriptionText = Buffer.from(bytes).toString('utf8'); - const descriptionLines = descriptionText.split(/(\r?\n)/); - return descriptionLines; - } catch { } - } - return ['']; -} - export async function renderOnSave(engine: MarkdownEngine, document: TextDocument) { // if its a notebook and we don't have a save hook for notebooks then don't // allow renderOnSave (b/c we can't detect the saves) diff --git a/apps/vscode/src/providers/preview/preview.ts b/apps/vscode/src/providers/preview/preview.ts index fdc0504d..ff62049e 100644 --- a/apps/vscode/src/providers/preview/preview.ts +++ b/apps/vscode/src/providers/preview/preview.ts @@ -74,7 +74,6 @@ import { haveNotebookSaveEvents, isQuartoShinyDoc, isQuartoShinyKnitrDoc, - isRPackage, renderOnSave, } from "./preview-util"; @@ -88,6 +87,7 @@ import { yamlErrorLocation, } from "./preview-errors"; import { ExtensionHost } from "../../host"; +import { isRPackage } from "../../r-utils"; tmp.setGracefulCleanup(); diff --git a/apps/vscode/src/r-utils.ts b/apps/vscode/src/r-utils.ts new file mode 100644 index 00000000..5067b5fe --- /dev/null +++ b/apps/vscode/src/r-utils.ts @@ -0,0 +1,29 @@ +/* + * r-utils.ts + * + * Copyright (C) 2025 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. + * + */ + +import * as vscode from 'vscode'; +import { isRPackage as isRPackageImpl } from "@utils/r-utils"; + +// Version that selects workspace folder +export async function isRPackage(): Promise { + if (vscode.workspace.workspaceFolders === undefined) { + return false; + } + + // Pick first workspace + const workspaceFolder = vscode.workspace.workspaceFolders[0]; + + return isRPackageImpl(workspaceFolder.uri); +} diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index b4379476..99af7b45 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "out", "lib": ["ES2021"], "sourceMap": true, - "strict": true /* enable all strict type-checking options */ + "strict": true, + "paths": { + "@utils/*": ["../utils/*"] + } + /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */