From e28172bd271fcf6adac7d09abfb40620f0ec5e3f Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 18 Dec 2024 17:34:41 -0500 Subject: [PATCH 1/2] Send structured `InitializationOptions` to the server In particular, this lets us respect `air.logLevel` and `air.dependencyLogLevels` on server startup User and workspace level settings are not used yet but are included for completeness --- .vscode/settings.json | 4 ++ crates/lsp/src/handlers_state.rs | 21 +++++-- crates/lsp/src/main_loop.rs | 12 +++- crates/lsp/src/settings.rs | 53 ++++++++++++++++ editors/code/package-lock.json | 59 +++++++++++++++++- editors/code/package.json | 29 +++++++++ editors/code/src/common/README.md | 4 ++ editors/code/src/common/constants.ts | 12 ++++ editors/code/src/common/log/logging.ts | 29 +++++++++ editors/code/src/common/setup.ts | 19 ++++++ editors/code/src/common/vscodeapi.ts | 53 ++++++++++++++++ editors/code/src/lsp.ts | 36 +++++++++-- editors/code/src/settings.ts | 85 ++++++++++++++++++++++++++ 13 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 crates/lsp/src/settings.rs create mode 100644 editors/code/src/common/README.md create mode 100644 editors/code/src/common/constants.ts create mode 100644 editors/code/src/common/log/logging.ts create mode 100644 editors/code/src/common/setup.ts create mode 100644 editors/code/src/common/vscodeapi.ts create mode 100644 editors/code/src/settings.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 22d345c8..c14b79d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,10 @@ "editor.formatOnSaveMode": "file", "editor.defaultFormatter": "rust-lang.rust-analyzer" }, + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "rust-analyzer.check.command": "clippy", "rust-analyzer.imports.prefix": "crate", "rust-analyzer.imports.granularity.group": "item", diff --git a/crates/lsp/src/handlers_state.rs b/crates/lsp/src/handlers_state.rs index a570cf31..9a6c890f 100644 --- a/crates/lsp/src/handlers_state.rs +++ b/crates/lsp/src/handlers_state.rs @@ -36,6 +36,7 @@ use crate::documents::Document; use crate::logging; use crate::logging::LogMessageSender; use crate::main_loop::LspState; +use crate::settings::InitializationOptions; use crate::state::workspace_uris; use crate::state::WorldState; @@ -65,17 +66,27 @@ pub(crate) fn initialize( state: &mut WorldState, log_tx: LogMessageSender, ) -> anyhow::Result { - // TODO: Get user specified options from `params.initialization_options` - let log_level = None; - let dependency_log_levels = None; + let InitializationOptions { + global_settings, + user_settings, + workspace_settings, + } = match params.initialization_options { + Some(initialization_options) => InitializationOptions::from_value(initialization_options), + None => InitializationOptions::default(), + }; logging::init_logging( log_tx, - log_level, - dependency_log_levels, + global_settings.log_level, + global_settings.dependency_log_levels, params.client_info.as_ref(), ); + // TODO: Should these be "pulled" using the LSP server->client `configuration()` + // request instead? + lsp_state.user_client_settings = user_settings; + lsp_state.workspace_client_settings = workspace_settings; + // Defaults to UTF-16 let mut position_encoding = None; diff --git a/crates/lsp/src/main_loop.rs b/crates/lsp/src/main_loop.rs index 780a635b..5bc48b3e 100644 --- a/crates/lsp/src/main_loop.rs +++ b/crates/lsp/src/main_loop.rs @@ -26,6 +26,8 @@ use crate::handlers_state; use crate::handlers_state::ConsoleInputs; use crate::logging::LogMessageSender; use crate::logging::LogState; +use crate::settings::ClientSettings; +use crate::settings::ClientWorkspaceSettings; use crate::state::WorldState; use crate::tower_lsp::LspMessage; use crate::tower_lsp::LspNotification; @@ -145,9 +147,15 @@ pub(crate) struct GlobalState { log_tx: Option, } -/// Unlike `WorldState`, `ParserState` cannot be cloned and is only accessed by +/// Unlike `WorldState`, `LspState` cannot be cloned and is only accessed by /// exclusive handlers. pub(crate) struct LspState { + /// User level [`ClientSettings`] sent over from the client + pub(crate) user_client_settings: ClientSettings, + + /// Workspace level [`ClientSettings`] sent over from the client + pub(crate) workspace_client_settings: Vec, + /// The negociated encoding for document positions. Note that documents are /// always stored as UTF-8 in Rust Strings. This encoding is only used to /// translate UTF-16 positions sent by the client to UTF-8 ones. @@ -165,6 +173,8 @@ pub(crate) struct LspState { impl Default for LspState { fn default() -> Self { Self { + user_client_settings: ClientSettings::default(), + workspace_client_settings: Vec::new(), // Default encoding specified in the LSP protocol position_encoding: PositionEncoding::Wide(WideEncoding::Utf16), parsers: Default::default(), diff --git a/crates/lsp/src/settings.rs b/crates/lsp/src/settings.rs new file mode 100644 index 00000000..456cf6ba --- /dev/null +++ b/crates/lsp/src/settings.rs @@ -0,0 +1,53 @@ +use serde::Deserialize; +use serde_json::Value; +use url::Url; + +// These settings are only needed once, typically for initialization. +// They are read at the global scope on the client side and are never refreshed. +#[derive(Debug, Deserialize, Default, Clone)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientGlobalSettings { + pub(crate) log_level: Option, + pub(crate) dependency_log_levels: Option, +} + +/// This is a direct representation of the user level settings schema sent +/// by the client. It is refreshed after configuration changes. +#[derive(Debug, Deserialize, Default, Clone)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientSettings {} + +/// This is a direct representation of the workspace level settings schema sent by the +/// client. It is the same as the user level settings with the addition of the workspace +/// path. +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientWorkspaceSettings { + pub(crate) url: Url, + #[serde(flatten)] + pub(crate) settings: ClientSettings, +} + +/// This is the exact schema for initialization options sent in by the client +/// during initialization. +#[derive(Debug, Deserialize, Default)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct InitializationOptions { + pub(crate) global_settings: ClientGlobalSettings, + pub(crate) user_settings: ClientSettings, + pub(crate) workspace_settings: Vec, +} + +impl InitializationOptions { + pub(crate) fn from_value(value: Value) -> Self { + serde_json::from_value(value) + .map_err(|err| { + tracing::error!("Failed to deserialize initialization options: {err}. Falling back to default client settings."); + }) + .unwrap_or_default() + } +} diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index 64d18a7a..9e8cd07a 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@types/p-queue": "^3.1.0", "adm-zip": "^0.5.16", + "fs-extra": "^11.2.0", "p-queue": "npm:@esm2cjs/p-queue@^7.3.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { "@types/adm-zip": "^0.5.6", + "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.9", "@types/node": "20.x", "@types/vscode": "^1.90.0", @@ -467,6 +469,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -481,6 +494,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -2115,6 +2138,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2215,7 +2252,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -2692,6 +2728,18 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4234,6 +4282,15 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", diff --git a/editors/code/package.json b/editors/code/package.json index 7166ddc6..16ffdfa9 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -6,6 +6,10 @@ "publisher": "Posit", "license": "MIT", "repository": "https://github.com/posit-dev/air", + "serverInfo": { + "name": "Air", + "module": "air" + }, "engines": { "vscode": "^1.90.0" }, @@ -35,6 +39,29 @@ ] } ], + "configuration": { + "properties": { + "air.logLevel": { + "default": null, + "markdownDescription": "Controls the log level of the language server.\n\n**This setting requires a restart to take effect.**", + "enum": [ + "error", + "warning", + "info", + "debug", + "trace" + ], + "scope": "application", + "type": "string" + }, + "air.dependencyLogLevels": { + "default": null, + "markdownDescription": "Controls the log level of the Rust crates that the language server depends on.\n\n**This setting requires a restart to take effect.**", + "scope": "application", + "type": "string" + } + } + }, "configurationDefaults": { "[r]": { "editor.defaultFormatter": "Posit.air" @@ -88,10 +115,12 @@ "@types/p-queue": "^3.1.0", "p-queue": "npm:@esm2cjs/p-queue@^7.3.0", "adm-zip": "^0.5.16", + "fs-extra": "^11.2.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { "@types/adm-zip": "^0.5.6", + "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.9", "@types/node": "20.x", "@types/vscode": "^1.90.0", diff --git a/editors/code/src/common/README.md b/editors/code/src/common/README.md new file mode 100644 index 00000000..31da5b8a --- /dev/null +++ b/editors/code/src/common/README.md @@ -0,0 +1,4 @@ +This directory contains "common" utilities used across language server extensions. + +They are pulled directly from this MIT licensed repo: +https://github.com/microsoft/vscode-python-tools-extension-template diff --git a/editors/code/src/common/constants.ts b/editors/code/src/common/constants.ts new file mode 100644 index 00000000..11fffe6f --- /dev/null +++ b/editors/code/src/common/constants.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// https://github.com/microsoft/vscode-python-tools-extension-template + +import * as path from "path"; + +const folderName = path.basename(__dirname); + +export const EXTENSION_ROOT_DIR = + folderName === "common" + ? path.dirname(path.dirname(__dirname)) + : path.dirname(__dirname); diff --git a/editors/code/src/common/log/logging.ts b/editors/code/src/common/log/logging.ts new file mode 100644 index 00000000..45f62f23 --- /dev/null +++ b/editors/code/src/common/log/logging.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// https://github.com/microsoft/vscode-python-tools-extension-template + +import * as util from "util"; +import { Disposable, OutputChannel } from "vscode"; + +type Arguments = unknown[]; +class OutputChannelLogger { + constructor(private readonly channel: OutputChannel) {} + + public traceLog(...data: Arguments): void { + this.channel.appendLine(util.format(...data)); + } +} + +let channel: OutputChannelLogger | undefined; +export function registerLogger(outputChannel: OutputChannel): Disposable { + channel = new OutputChannelLogger(outputChannel); + return { + dispose: () => { + channel = undefined; + }, + }; +} + +export function traceLog(...args: Arguments): void { + channel?.traceLog(...args); +} diff --git a/editors/code/src/common/setup.ts b/editors/code/src/common/setup.ts new file mode 100644 index 00000000..abd33a6d --- /dev/null +++ b/editors/code/src/common/setup.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// https://github.com/microsoft/vscode-python-tools-extension-template + +import * as path from "path"; +import * as fs from "fs-extra"; +import { EXTENSION_ROOT_DIR } from "./constants"; + +export interface IServerInfo { + name: string; + module: string; +} + +export function loadServerDefaults(): IServerInfo { + const packageJson = path.join(EXTENSION_ROOT_DIR, "package.json"); + const content = fs.readFileSync(packageJson).toString(); + const config = JSON.parse(content); + return config.serverInfo as IServerInfo; +} diff --git a/editors/code/src/common/vscodeapi.ts b/editors/code/src/common/vscodeapi.ts new file mode 100644 index 00000000..e43f109f --- /dev/null +++ b/editors/code/src/common/vscodeapi.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// https://github.com/microsoft/vscode-python-tools-extension-template + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + commands, + ConfigurationScope, + Disposable, + LogOutputChannel, + Uri, + window, + workspace, + WorkspaceConfiguration, + WorkspaceFolder, +} from "vscode"; + +export function createOutputChannel(name: string): LogOutputChannel { + return window.createOutputChannel(name, { log: true }); +} + +export function getConfiguration( + config: string, + scope?: ConfigurationScope +): WorkspaceConfiguration { + return workspace.getConfiguration(config, scope); +} + +export function registerCommand( + command: string, + callback: (...args: any[]) => any, + thisArg?: any +): Disposable { + return commands.registerCommand(command, callback, thisArg); +} + +export const { onDidChangeConfiguration } = workspace; + +export function isVirtualWorkspace(): boolean { + const isVirtual = + workspace.workspaceFolders && + workspace.workspaceFolders.every((f) => f.uri.scheme !== "file"); + return !!isVirtual; +} + +export function getWorkspaceFolders(): readonly WorkspaceFolder[] { + return workspace.workspaceFolders ?? []; +} + +export function getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined { + return workspace.getWorkspaceFolder(uri); +} diff --git a/editors/code/src/lsp.ts b/editors/code/src/lsp.ts index 18a49004..8aede096 100644 --- a/editors/code/src/lsp.ts +++ b/editors/code/src/lsp.ts @@ -1,6 +1,14 @@ import * as vscode from "vscode"; import * as lc from "vscode-languageclient/node"; import { default as PQueue } from "p-queue"; +import { IServerInfo, loadServerDefaults } from "./common/setup"; +import { registerLogger, traceLog } from "./common/log/logging"; +import { + getGlobalSettings, + getUserSettings, + getWorkspaceSettings, + IInitializationOptions, +} from "./settings"; // All session management operations are put on a queue. They can't run // concurrently and either result in a started or stopped state. Starting when @@ -14,6 +22,8 @@ enum State { export class Lsp { public client: lc.LanguageClient | null = null; + private serverInfo: IServerInfo; + // We use the same output channel for all LSP instances (e.g. a new instance // after a restart) to avoid having multiple channels in the Output viewpane. private channel: vscode.OutputChannel; @@ -23,7 +33,8 @@ export class Lsp { constructor(context: vscode.ExtensionContext) { this.channel = vscode.window.createOutputChannel("Air Language Server"); - context.subscriptions.push(this.channel); + context.subscriptions.push(this.channel, registerLogger(this.channel)); + this.serverInfo = loadServerDefaults(); this.stateQueue = new PQueue({ concurrency: 1 }); } @@ -52,7 +63,23 @@ export class Lsp { return; } - let options: lc.ServerOptions = { + // Log server information + traceLog(`Name: ${this.serverInfo.name}`); + traceLog(`Module: ${this.serverInfo.module}`); + + const globalSettings = await getGlobalSettings(this.serverInfo.module); + const userSettings = await getUserSettings(this.serverInfo.module); + const workspaceSettings = await getWorkspaceSettings( + this.serverInfo.module + ); + + const initializationOptions: IInitializationOptions = { + globalSettings, + userSettings, + workspaceSettings, + }; + + let serverOptions: lc.ServerOptions = { command: "air", args: ["lsp"], }; @@ -71,13 +98,14 @@ export class Lsp { vscode.workspace.createFileSystemWatcher("**/*.[Rr]"), }, outputChannel: this.channel, + initializationOptions: initializationOptions, }; const client = new lc.LanguageClient( "airLanguageServer", "Air Language Server", - options, - clientOptions, + serverOptions, + clientOptions ); await client.start(); diff --git a/editors/code/src/settings.ts b/editors/code/src/settings.ts new file mode 100644 index 00000000..bfe4692f --- /dev/null +++ b/editors/code/src/settings.ts @@ -0,0 +1,85 @@ +import { WorkspaceConfiguration, WorkspaceFolder } from "vscode"; +import { getConfiguration, getWorkspaceFolders } from "./common/vscodeapi"; + +type LogLevel = "error" | "warn" | "info" | "debug" | "trace"; + +// One time settings that aren't ever refreshed within the extension's lifetime. +// They are read at the user (i.e. global) scope. +export interface IGlobalSettings { + logLevel?: LogLevel; + dependencyLogLevels?: string; +} + +// Client representation of user level client settings. +// TODO: These are refreshed using a `Configuration` LSP request from the server. +// (It is possible we should ONLY get these through `Configuration` and not through +// initializationOptions) +export interface ISettings {} + +// Client representation of workspace level client settings. +// Same as the user level settings, with the addition of the workspace path. +// TODO: These are refreshed using a `Configuration` LSP request from the server. +// (It is possible we should ONLY get these through `Configuration` and not through +// initializationOptions) +export interface IWorkspaceSettings { + url: string; + settings: ISettings; +} + +// This is a direct representation of the Client settings sent to the Server in the +// `initializationOptions` field of `InitializeParams` +export type IInitializationOptions = { + globalSettings: IGlobalSettings; + userSettings: ISettings; + workspaceSettings: IWorkspaceSettings[]; +}; + +export async function getGlobalSettings( + namespace: string +): Promise { + const config = getConfiguration(namespace); + + return { + logLevel: getOptionalUserValue(config, "logLevel"), + dependencyLogLevels: getOptionalUserValue( + config, + "dependencyLogLevels" + ), + }; +} + +export async function getUserSettings(namespace: string): Promise { + const config = getConfiguration(namespace); + + return {}; +} + +export function getWorkspaceSettings( + namespace: string +): Promise { + return Promise.all( + getWorkspaceFolders().map((workspaceFolder) => + getWorkspaceFolderSettings(namespace, workspaceFolder) + ) + ); +} + +async function getWorkspaceFolderSettings( + namespace: string, + workspace: WorkspaceFolder +): Promise { + const config = getConfiguration(namespace, workspace.uri); + + return { + url: workspace.uri.toString(), + settings: {}, + }; +} + +function getOptionalUserValue( + config: WorkspaceConfiguration, + key: string +): T | undefined { + const inspect = config.inspect(key); + return inspect?.globalValue; +} From 64f6f3082e3114605c25e4301fd9042286c68759 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 18 Dec 2024 17:35:06 -0500 Subject: [PATCH 2/2] Add support for reading `air.toml` in the CLI and LSP --- Cargo.lock | 78 +++++- Cargo.toml | 7 +- crates/air/Cargo.toml | 3 +- crates/air/src/commands/format.rs | 88 ++---- crates/air_r_formatter/src/context.rs | 14 + crates/air_r_formatter/src/lib.rs | 1 + crates/air_r_formatter/src/options.rs | 3 + .../src/options/magic_line_break.rs | 44 +++ crates/lsp/Cargo.toml | 1 + crates/lsp/src/capabilities.rs | 38 +++ crates/lsp/src/error.rs | 52 ++++ crates/lsp/src/handlers.rs | 40 ++- crates/lsp/src/handlers_format.rs | 12 +- crates/lsp/src/handlers_state.rs | 98 ++++--- crates/lsp/src/lib.rs | 4 + crates/lsp/src/main_loop.rs | 51 +++- crates/lsp/src/state.rs | 8 - crates/lsp/src/tower_lsp.rs | 2 +- crates/lsp/src/workspaces.rs | 174 ++++++++++++ crates/workspace/Cargo.toml | 31 +++ crates/workspace/src/lib.rs | 4 + crates/workspace/src/resolve.rs | 252 ++++++++++++++++++ crates/workspace/src/settings.rs | 60 +++++ crates/workspace/src/settings/indent_style.rs | 54 ++++ crates/workspace/src/settings/indent_width.rs | 148 ++++++++++ crates/workspace/src/settings/line_ending.rs | 31 +++ crates/workspace/src/settings/line_length.rs | 145 ++++++++++ .../src/settings/magic_line_break.rs | 53 ++++ ...__tests__deserialize_oob_indent_width.snap | 9 + ...h__tests__deserialize_oob_line_length.snap | 9 + crates/workspace/src/toml.rs | 117 ++++++++ crates/workspace/src/toml_options.rs | 121 +++++++++ editors/code/src/lsp.ts | 5 - 33 files changed, 1603 insertions(+), 154 deletions(-) create mode 100644 crates/air_r_formatter/src/options.rs create mode 100644 crates/air_r_formatter/src/options/magic_line_break.rs create mode 100644 crates/lsp/src/capabilities.rs create mode 100644 crates/lsp/src/error.rs create mode 100644 crates/lsp/src/workspaces.rs create mode 100644 crates/workspace/Cargo.toml create mode 100644 crates/workspace/src/lib.rs create mode 100644 crates/workspace/src/resolve.rs create mode 100644 crates/workspace/src/settings.rs create mode 100644 crates/workspace/src/settings/indent_style.rs create mode 100644 crates/workspace/src/settings/indent_width.rs create mode 100644 crates/workspace/src/settings/line_ending.rs create mode 100644 crates/workspace/src/settings/line_length.rs create mode 100644 crates/workspace/src/settings/magic_line_break.rs create mode 100644 crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap create mode 100644 crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap create mode 100644 crates/workspace/src/toml.rs create mode 100644 crates/workspace/src/toml_options.rs diff --git a/Cargo.lock b/Cargo.lock index a17078ce..5db086d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,7 @@ dependencies = [ "thiserror 2.0.5", "tokio", "tracing", + "workspace", ] [[package]] @@ -328,6 +329,8 @@ dependencies = [ "drop_bomb", "indexmap", "rustc-hash", + "schemars", + "serde", "tracing", "unicode-width", ] @@ -1346,6 +1349,7 @@ dependencies = [ "triomphe", "url", "uuid", + "workspace", ] [[package]] @@ -1672,9 +1676,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustix" @@ -1849,6 +1853,15 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2203,6 +2216,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2637,6 +2684,33 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "workspace" +version = "0.1.0" +dependencies = [ + "air_r_formatter", + "anyhow", + "biome_formatter", + "fs", + "ignore", + "insta", + "line_ending", + "rustc-hash", + "serde", + "tempfile", + "thiserror 2.0.5", + "toml", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index b6b56566..2627d3a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ line_ending = { path = "./crates/line_ending" } lsp = { path = "./crates/lsp" } lsp_test = { path = "./crates/lsp_test" } tests_macros = { path = "./crates/tests_macros" } +workspace = { path = "./crates/workspace" } anyhow = "1.0.89" assert_matches = "1.5.0" @@ -59,14 +60,17 @@ line-index = "0.1.2" memchr = "2.7.4" path-absolutize = "3.1.1" proc-macro2 = "1.0.86" -serde = { version = "1.0.215", features = ["derive"] } +rustc-hash = "2.1.0" +serde = "1.0.215" serde_json = "1.0.132" struct-field-names-as-array = "0.3.0" strum = "0.26" +tempfile = "3.9.0" time = "0.3.37" thiserror = "2.0.5" tokio = { version = "1.41.1" } tokio-util = "0.7.12" +toml = "0.8.19" # For https://github.com/ebkalderon/tower-lsp/pull/428 tower-lsp = { git = "https://github.com/lionel-/tower-lsp", branch = "bugfix/patches" } tracing = { version = "0.1.40", default-features = false, features = ["std"] } @@ -124,7 +128,6 @@ unnecessary_join = "warn" unnested_or_patterns = "warn" unreadable_literal = "warn" verbose_bit_mask = "warn" -zero_sized_map_values = "warn" # restriction cfg_not_test = "warn" diff --git a/crates/air/Cargo.toml b/crates/air/Cargo.toml index 4df75281..9736f92b 100644 --- a/crates/air/Cargo.toml +++ b/crates/air/Cargo.toml @@ -28,9 +28,10 @@ lsp = { workspace = true } thiserror = { workspace = true } tokio = "1.41.1" tracing = { workspace = true } +workspace = { workspace = true } [dev-dependencies] -tempfile = "3.9.0" +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/air/src/commands/format.rs b/crates/air/src/commands/format.rs index 46c127a6..d2feacf8 100644 --- a/crates/air/src/commands/format.rs +++ b/crates/air/src/commands/format.rs @@ -8,23 +8,32 @@ use std::path::PathBuf; use air_r_formatter::context::RFormatOptions; use air_r_parser::RParserOptions; use fs::relativize_path; -use ignore::DirEntry; use itertools::Either; use itertools::Itertools; -use line_ending::LineEnding; use thiserror::Error; +use workspace::resolve::discover_r_file_paths; +use workspace::resolve::SettingsResolver; +use workspace::settings::FormatSettings; +use workspace::settings::Settings; use crate::args::FormatCommand; use crate::ExitStatus; pub(crate) fn format(command: FormatCommand) -> anyhow::Result { let mode = FormatMode::from_command(&command); - let paths = resolve_paths(&command.paths); + + let paths = discover_r_file_paths(&command.paths); + + let mut resolver = SettingsResolver::new(Settings::default()); + resolver.load_from_paths(&command.paths)?; let (actions, errors): (Vec<_>, Vec<_>) = paths .into_iter() .map(|path| match path { - Ok(path) => format_file(path, mode), + Ok(path) => { + let settings = resolver.resolve_or_fallback(&path); + format_file(path, mode, &settings.format) + } Err(err) => Err(err.into()), }) .partition_map(|result| match result { @@ -99,62 +108,6 @@ fn write_changed(actions: &[FormatFileAction], f: &mut impl Write) -> io::Result Ok(()) } -fn resolve_paths(paths: &[PathBuf]) -> Vec> { - let paths: Vec = paths.iter().map(fs::normalize_path).collect(); - - let (first_path, paths) = paths - .split_first() - .expect("Clap should ensure at least 1 path is supplied."); - - // TODO: Parallel directory visitor - let mut builder = ignore::WalkBuilder::new(first_path); - - for path in paths { - builder.add(path); - } - - let mut out = Vec::new(); - - for path in builder.build() { - match path { - Ok(entry) => { - if let Some(path) = is_valid_path(entry) { - out.push(Ok(path)); - } - } - Err(err) => { - out.push(Err(err)); - } - } - } - - out -} - -// Decide whether or not to accept an `entry` based on include/exclude rules. -fn is_valid_path(entry: DirEntry) -> Option { - // Ignore directories - if entry.file_type().map_or(true, |ft| ft.is_dir()) { - return None; - } - - // Accept all files that are passed-in directly, even non-R files - if entry.depth() == 0 { - let path = entry.into_path(); - return Some(path); - } - - // Otherwise check if we should accept this entry - // TODO: Many other checks based on user exclude/includes - let path = entry.into_path(); - - if !fs::has_r_extension(&path) { - return None; - } - - Some(path) -} - pub(crate) enum FormatFileAction { Formatted(PathBuf), Unchanged, @@ -166,18 +119,15 @@ impl FormatFileAction { } } -// TODO: Take workspace `FormatOptions` that get resolved to `RFormatOptions` -// for the formatter here. Respect user specified `LineEnding` option too, and -// only use inferred endings when `FormatOptions::LineEnding::Auto` is used. -fn format_file(path: PathBuf, mode: FormatMode) -> Result { +fn format_file( + path: PathBuf, + mode: FormatMode, + settings: &FormatSettings, +) -> Result { let source = std::fs::read_to_string(&path) .map_err(|err| FormatCommandError::Read(path.clone(), err))?; - let line_ending = match line_ending::infer(&source) { - LineEnding::Lf => biome_formatter::LineEnding::Lf, - LineEnding::Crlf => biome_formatter::LineEnding::Crlf, - }; - let options = RFormatOptions::default().with_line_ending(line_ending); + let options = settings.to_format_options(&source); let source = line_ending::normalize(source); let formatted = match format_source(source.as_str(), options) { diff --git a/crates/air_r_formatter/src/context.rs b/crates/air_r_formatter/src/context.rs index 84acba35..a4956636 100644 --- a/crates/air_r_formatter/src/context.rs +++ b/crates/air_r_formatter/src/context.rs @@ -17,6 +17,7 @@ use biome_formatter::TransformSourceMap; use crate::comments::FormatRLeadingComment; use crate::comments::RCommentStyle; use crate::comments::RComments; +use crate::options::MagicLineBreak; pub struct RFormatContext { options: RFormatOptions, @@ -77,6 +78,10 @@ pub struct RFormatOptions { /// The max width of a line. Defaults to 80. line_width: LineWidth, + + // TODO: Actually use this internally! + /// The behavior of magic line breaks. + magic_line_break: MagicLineBreak, } impl RFormatOptions { @@ -106,6 +111,11 @@ impl RFormatOptions { self } + pub fn with_magic_line_break(mut self, magic_line_break: MagicLineBreak) -> Self { + self.magic_line_break = magic_line_break; + self + } + pub fn set_indent_style(&mut self, indent_style: IndentStyle) { self.indent_style = indent_style; } @@ -121,6 +131,10 @@ impl RFormatOptions { pub fn set_line_width(&mut self, line_width: LineWidth) { self.line_width = line_width; } + + pub fn set_magic_line_break(&mut self, magic_line_break: MagicLineBreak) { + self.magic_line_break = magic_line_break; + } } impl FormatOptions for RFormatOptions { diff --git a/crates/air_r_formatter/src/lib.rs b/crates/air_r_formatter/src/lib.rs index 93fff37f..760efe64 100644 --- a/crates/air_r_formatter/src/lib.rs +++ b/crates/air_r_formatter/src/lib.rs @@ -21,6 +21,7 @@ use crate::cst::FormatRSyntaxNode; pub mod comments; pub mod context; mod cst; +pub mod options; mod prelude; mod r; pub(crate) mod separated; diff --git a/crates/air_r_formatter/src/options.rs b/crates/air_r_formatter/src/options.rs new file mode 100644 index 00000000..7c04088a --- /dev/null +++ b/crates/air_r_formatter/src/options.rs @@ -0,0 +1,3 @@ +mod magic_line_break; + +pub use magic_line_break::*; diff --git a/crates/air_r_formatter/src/options/magic_line_break.rs b/crates/air_r_formatter/src/options/magic_line_break.rs new file mode 100644 index 00000000..ec331bf5 --- /dev/null +++ b/crates/air_r_formatter/src/options/magic_line_break.rs @@ -0,0 +1,44 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index 3c726341..6a356eb2 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -42,6 +42,7 @@ tree-sitter-r.workspace = true triomphe.workspace = true url.workspace = true uuid = { workspace = true, features = ["v4"] } +workspace = { workspace = true } [dev-dependencies] assert_matches.workspace = true diff --git a/crates/lsp/src/capabilities.rs b/crates/lsp/src/capabilities.rs new file mode 100644 index 00000000..77c076ad --- /dev/null +++ b/crates/lsp/src/capabilities.rs @@ -0,0 +1,38 @@ +use tower_lsp::lsp_types::ClientCapabilities; +use tower_lsp::lsp_types::PositionEncodingKind; + +#[derive(Debug, Default)] +pub(crate) struct ResolvedClientCapabilities { + pub(crate) position_encodings: Vec, + pub(crate) dynamic_registration_for_did_change_configuration: bool, + pub(crate) dynamic_registration_for_did_change_watched_files: bool, +} + +impl ResolvedClientCapabilities { + pub(crate) fn new(capabilities: ClientCapabilities) -> Self { + let position_encodings = capabilities + .general + .and_then(|general_client_capabilities| general_client_capabilities.position_encodings) + .unwrap_or(vec![PositionEncodingKind::UTF16]); + + let dynamic_registration_for_did_change_configuration = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_configuration) + .and_then(|did_change_configuration| did_change_configuration.dynamic_registration) + .unwrap_or(false); + + let dynamic_registration_for_did_change_watched_files = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files) + .and_then(|watched_files| watched_files.dynamic_registration) + .unwrap_or_default(); + + Self { + position_encodings, + dynamic_registration_for_did_change_configuration, + dynamic_registration_for_did_change_watched_files, + } + } +} diff --git a/crates/lsp/src/error.rs b/crates/lsp/src/error.rs new file mode 100644 index 00000000..fe19c650 --- /dev/null +++ b/crates/lsp/src/error.rs @@ -0,0 +1,52 @@ +/// A tool for collecting multiple anyhow errors into a single [`anyhow::Result`] +/// +/// Only applicable if the intended `Ok()` value at the end is `()`. +#[derive(Debug, Default)] +pub(crate) struct ErrorVec { + errors: Vec, +} + +impl ErrorVec { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Conditionally push to the error vector if the `result` is an `Err` case + pub(crate) fn push_err(&mut self, result: anyhow::Result) { + match result { + Ok(_) => (), + Err(error) => self.push(error), + } + } + + /// Push a new error to the error vector + pub(crate) fn push(&mut self, error: anyhow::Error) { + self.errors.push(error); + } + + /// Convert a error vector into a single [`anyhow::Result`] that knows how to print + /// each of the individual errors + pub(crate) fn into_result(self) -> anyhow::Result<()> { + if self.errors.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!(self)) + } + } +} + +impl std::error::Error for ErrorVec {} + +impl std::fmt::Display for ErrorVec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.errors.len() > 1 { + f.write_str("Multiple errors:\n")?; + } + + for error in &self.errors { + std::fmt::Display::fmt(error, f)?; + } + + Ok(()) + } +} diff --git a/crates/lsp/src/handlers.rs b/crates/lsp/src/handlers.rs index 53cb5ebc..0f7fa847 100644 --- a/crates/lsp/src/handlers.rs +++ b/crates/lsp/src/handlers.rs @@ -7,6 +7,8 @@ use struct_field_names_as_array::FieldNamesAsArray; use tower_lsp::lsp_types; +use tower_lsp::lsp_types::DidChangeWatchedFilesRegistrationOptions; +use tower_lsp::lsp_types::FileSystemWatcher; use tower_lsp::Client; use tracing::Instrument; @@ -24,30 +26,54 @@ pub(crate) async fn handle_initialized( let span = tracing::info_span!("handle_initialized").entered(); // Register capabilities to the client - let mut regs: Vec = vec![]; + let mut registrations: Vec = vec![]; - if lsp_state.needs_registration.did_change_configuration { + if lsp_state + .capabilities + .dynamic_registration_for_did_change_configuration + { // The `didChangeConfiguration` request instructs the client to send // a notification when the tracked settings have changed. // // Note that some settings, such as editor indentation properties, may be // changed by extensions or by the user without changing the actual // underlying setting. Unfortunately we don't receive updates in that case. - let mut config_document_regs = collect_regs( + let mut config_document_registrations = collect_regs( VscDocumentConfig::FIELD_NAMES_AS_ARRAY.to_vec(), VscDocumentConfig::section_from_key, ); - let mut config_diagnostics_regs: Vec = collect_regs( + let mut config_diagnostics_registrations: Vec = collect_regs( VscDiagnosticsConfig::FIELD_NAMES_AS_ARRAY.to_vec(), VscDiagnosticsConfig::section_from_key, ); - regs.append(&mut config_document_regs); - regs.append(&mut config_diagnostics_regs); + registrations.append(&mut config_document_registrations); + registrations.append(&mut config_diagnostics_registrations); + } + + if lsp_state + .capabilities + .dynamic_registration_for_did_change_watched_files + { + let watch_air_toml_registration = lsp_types::Registration { + id: uuid::Uuid::new_v4().to_string(), + method: "workspace/didChangeWatchedFiles".into(), + register_options: Some( + serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { + watchers: vec![FileSystemWatcher { + glob_pattern: lsp_types::GlobPattern::String("**/air.toml".into()), + kind: None, + }], + }) + .unwrap(), + ), + }; + + registrations.push(watch_air_toml_registration); } client - .register_capability(regs) + .register_capability(registrations) .instrument(span.exit()) .await?; Ok(()) diff --git a/crates/lsp/src/handlers_format.rs b/crates/lsp/src/handlers_format.rs index c9de94b3..c67ba78e 100644 --- a/crates/lsp/src/handlers_format.rs +++ b/crates/lsp/src/handlers_format.rs @@ -12,28 +12,26 @@ use biome_rowan::{AstNode, Language, SyntaxElement}; use biome_text_size::{TextRange, TextSize}; use tower_lsp::lsp_types; +use crate::main_loop::LspState; use crate::state::WorldState; use crate::{from_proto, to_proto}; #[tracing::instrument(level = "info", skip_all)] pub(crate) fn document_formatting( params: lsp_types::DocumentFormattingParams, + lsp_state: &LspState, state: &WorldState, ) -> anyhow::Result>> { let doc = state.get_document(¶ms.text_document.uri)?; - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; - - // TODO: Handle FormattingOptions - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); + let settings = lsp_state.document_settings(¶ms.text_document.uri); + let format_options = settings.format.to_format_options(&doc.contents); if doc.parse.has_errors() { return Err(anyhow::anyhow!("Can't format when there are parse errors.")); } - let formatted = format_node(options.clone(), &doc.parse.syntax())?; + let formatted = format_node(format_options, &doc.parse.syntax())?; let output = formatted.print()?.into_code(); // Do we need to check that `doc` is indeed an R file? What about special diff --git a/crates/lsp/src/handlers_state.rs b/crates/lsp/src/handlers_state.rs index 9a6c890f..a63c284a 100644 --- a/crates/lsp/src/handlers_state.rs +++ b/crates/lsp/src/handlers_state.rs @@ -7,12 +7,15 @@ use anyhow::anyhow; use biome_lsp_converters::PositionEncoding; +use biome_lsp_converters::WideEncoding; use serde_json::Value; use struct_field_names_as_array::FieldNamesAsArray; use tower_lsp::lsp_types; use tower_lsp::lsp_types::ConfigurationItem; use tower_lsp::lsp_types::DidChangeConfigurationParams; use tower_lsp::lsp_types::DidChangeTextDocumentParams; +use tower_lsp::lsp_types::DidChangeWatchedFilesParams; +use tower_lsp::lsp_types::DidChangeWorkspaceFoldersParams; use tower_lsp::lsp_types::DidCloseTextDocumentParams; use tower_lsp::lsp_types::DidOpenTextDocumentParams; use tower_lsp::lsp_types::FormattingOptions; @@ -27,18 +30,22 @@ use tower_lsp::lsp_types::WorkspaceFoldersServerCapabilities; use tower_lsp::lsp_types::WorkspaceServerCapabilities; use tracing::Instrument; use url::Url; +use workspace::settings::Settings; +use crate::capabilities::ResolvedClientCapabilities; use crate::config::indent_style_from_lsp; use crate::config::DocumentConfig; use crate::config::VscDiagnosticsConfig; use crate::config::VscDocumentConfig; use crate::documents::Document; +use crate::error::ErrorVec; use crate::logging; use crate::logging::LogMessageSender; use crate::main_loop::LspState; use crate::settings::InitializationOptions; use crate::state::workspace_uris; use crate::state::WorldState; +use crate::workspaces::WorkspaceSettingsResolver; // Handlers that mutate the world state @@ -63,7 +70,6 @@ pub struct ConsoleInputs { pub(crate) fn initialize( params: InitializeParams, lsp_state: &mut LspState, - state: &mut WorldState, log_tx: LogMessageSender, ) -> anyhow::Result { let InitializationOptions { @@ -87,44 +93,30 @@ pub(crate) fn initialize( lsp_state.user_client_settings = user_settings; lsp_state.workspace_client_settings = workspace_settings; - // Defaults to UTF-16 - let mut position_encoding = None; - - if let Some(caps) = params.capabilities.general { - // If the client supports UTF-8 we use that, even if it's not its - // preferred encoding (at position 0). Otherwise we use the mandatory - // UTF-16 encoding that all clients and servers must support, even if - // the client would have preferred UTF-32. Note that VSCode and Positron - // only support UTF-16. - if let Some(caps) = caps.position_encodings { - if caps.contains(&lsp_types::PositionEncodingKind::UTF8) { - lsp_state.position_encoding = PositionEncoding::Utf8; - position_encoding = Some(lsp_types::PositionEncodingKind::UTF8); - } - } - } - - // Take note of supported capabilities so we can register them in the - // `Initialized` handler - if let Some(ws_caps) = params.capabilities.workspace { - if matches!(ws_caps.did_change_configuration, Some(caps) if matches!(caps.dynamic_registration, Some(true))) - { - lsp_state.needs_registration.did_change_configuration = true; - } - } + // Initialize the workspace settings resolver using the initial set of client provided `workspace_folders` + lsp_state.workspace_settings_resolver = WorkspaceSettingsResolver::from_workspace_folders( + params.workspace_folders.unwrap_or_default(), + Settings::default(), + ); - // Initialize the workspace folders - let mut folders: Vec = Vec::new(); - if let Some(workspace_folders) = params.workspace_folders { - for folder in workspace_folders.iter() { - state.workspace.folders.push(folder.uri.clone()); - if let Ok(path) = folder.uri.to_file_path() { - if let Some(path) = path.to_str() { - folders.push(path.to_string()); - } - } - } - } + lsp_state.capabilities = ResolvedClientCapabilities::new(params.capabilities); + + // If the client supports UTF-8 we use that, even if it's not its + // preferred encoding (at position 0). Otherwise we use the mandatory + // UTF-16 encoding that all clients and servers must support, even if + // the client would have preferred UTF-32. Note that VSCode and Positron + // only support UTF-16. + let position_encoding = if lsp_state + .capabilities + .position_encodings + .contains(&lsp_types::PositionEncodingKind::UTF8) + { + lsp_state.position_encoding = PositionEncoding::Utf8; + Some(lsp_types::PositionEncodingKind::UTF8) + } else { + lsp_state.position_encoding = PositionEncoding::Wide(WideEncoding::Utf16); + Some(lsp_types::PositionEncodingKind::UTF16) + }; Ok(InitializeResult { server_info: Some(ServerInfo { @@ -210,6 +202,36 @@ pub(crate) async fn did_change_configuration( .await } +pub(crate) fn did_change_workspace_folders( + params: DidChangeWorkspaceFoldersParams, + lsp_state: &mut LspState, +) -> anyhow::Result<()> { + // Collect all `errors` to ensure we don't drop events after a first error + let mut errors = ErrorVec::new(); + + for lsp_types::WorkspaceFolder { uri, .. } in params.event.added { + errors.push_err(lsp_state.open_workspace_folder(&uri, Settings::default())); + } + for lsp_types::WorkspaceFolder { uri, .. } in params.event.removed { + errors.push_err(lsp_state.close_workspace_folder(&uri)); + } + + errors.into_result() +} + +pub(crate) fn did_change_watched_files( + params: DidChangeWatchedFilesParams, + lsp_state: &mut LspState, +) -> anyhow::Result<()> { + for change in ¶ms.changes { + lsp_state + .workspace_settings_resolver + .reload_workspaces_matched_by_url(&change.uri); + } + + Ok(()) +} + #[tracing::instrument(level = "info", skip_all)] pub(crate) fn did_change_formatting_options( uri: &Url, diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs index 65451b9f..f6b29376 100644 --- a/crates/lsp/src/lib.rs +++ b/crates/lsp/src/lib.rs @@ -3,10 +3,12 @@ pub use tower_lsp::start_lsp; +pub mod capabilities; pub mod config; pub mod crates; pub mod documents; pub mod encoding; +pub mod error; pub mod from_proto; pub mod handlers; pub mod handlers_ext; @@ -15,9 +17,11 @@ pub mod handlers_state; pub mod logging; pub mod main_loop; pub mod rust_analyzer; +pub mod settings; pub mod state; pub mod to_proto; pub mod tower_lsp; +pub mod workspaces; #[cfg(test)] pub mod test_utils; diff --git a/crates/lsp/src/main_loop.rs b/crates/lsp/src/main_loop.rs index 5bc48b3e..9324889c 100644 --- a/crates/lsp/src/main_loop.rs +++ b/crates/lsp/src/main_loop.rs @@ -18,7 +18,10 @@ use tokio::task::JoinHandle; use tower_lsp::lsp_types::Diagnostic; use tower_lsp::Client; use url::Url; +use workspace::resolve::SettingsResolver; +use workspace::settings::Settings; +use crate::capabilities::ResolvedClientCapabilities; use crate::handlers; use crate::handlers_ext; use crate::handlers_format; @@ -33,6 +36,7 @@ use crate::tower_lsp::LspMessage; use crate::tower_lsp::LspNotification; use crate::tower_lsp::LspRequest; use crate::tower_lsp::LspResponse; +use crate::workspaces::WorkspaceSettingsResolver; pub(crate) type TokioUnboundedSender = tokio::sync::mpsc::UnboundedSender; pub(crate) type TokioUnboundedReceiver = tokio::sync::mpsc::UnboundedReceiver; @@ -156,6 +160,9 @@ pub(crate) struct LspState { /// Workspace level [`ClientSettings`] sent over from the client pub(crate) workspace_client_settings: Vec, + /// Resolver to look up [`Settings`] given a document [`Url`] + pub(crate) workspace_settings_resolver: WorkspaceSettingsResolver, + /// The negociated encoding for document positions. Note that documents are /// always stored as UTF-8 in Rust Strings. This encoding is only used to /// translate UTF-16 positions sent by the client to UTF-8 ones. @@ -164,10 +171,8 @@ pub(crate) struct LspState { /// The set of tree-sitter document parsers managed by the `GlobalState`. pub(crate) parsers: HashMap, - /// List of capabilities for which we need to send a registration request - /// when we get the `Initialized` notification. - pub(crate) needs_registration: ClientCaps, - // Add handle to aux loop here? + /// List of client capabilities that we care about + pub(crate) capabilities: ResolvedClientCapabilities, } impl Default for LspState { @@ -175,17 +180,35 @@ impl Default for LspState { Self { user_client_settings: ClientSettings::default(), workspace_client_settings: Vec::new(), + workspace_settings_resolver: WorkspaceSettingsResolver::default(), // Default encoding specified in the LSP protocol position_encoding: PositionEncoding::Wide(WideEncoding::Utf16), parsers: Default::default(), - needs_registration: Default::default(), + capabilities: ResolvedClientCapabilities::default(), } } } -#[derive(Debug, Default)] -pub(crate) struct ClientCaps { - pub(crate) did_change_configuration: bool, +impl LspState { + pub(crate) fn document_settings(&self, url: &Url) -> &Settings { + self.workspace_settings_resolver.settings_for_url(url) + } + + pub(crate) fn open_workspace_folder( + &mut self, + url: &Url, + fallback: Settings, + ) -> anyhow::Result<()> { + self.workspace_settings_resolver + .open_workspace_folder(url, fallback) + } + + pub(crate) fn close_workspace_folder( + &mut self, + url: &Url, + ) -> anyhow::Result> { + self.workspace_settings_resolver.close_workspace_folder(url) + } } enum LoopControl { @@ -310,14 +333,14 @@ impl GlobalState { LspNotification::Initialized(_params) => { handlers::handle_initialized(&self.client, &self.lsp_state).await?; }, - LspNotification::DidChangeWorkspaceFolders(_params) => { - // TODO: Restart indexer with new folders. + LspNotification::DidChangeWorkspaceFolders(params) => { + handlers_state::did_change_workspace_folders(params, &mut self.lsp_state)?; }, LspNotification::DidChangeConfiguration(params) => { handlers_state::did_change_configuration(params, &self.client, &mut self.world).await?; }, - LspNotification::DidChangeWatchedFiles(_params) => { - // TODO: Re-index the changed files. + LspNotification::DidChangeWatchedFiles(params) => { + handlers_state::did_change_watched_files(params, &mut self.lsp_state)?; }, LspNotification::DidOpenTextDocument(params) => { handlers_state::did_open(params, &self.lsp_state, &mut self.world)?; @@ -339,14 +362,14 @@ impl GlobalState { LspRequest::Initialize(params) => { // Unwrap: `Initialize` method should only be called once. let log_tx = self.log_tx.take().unwrap(); - respond(tx, handlers_state::initialize(params, &mut self.lsp_state, &mut self.world, log_tx), LspResponse::Initialize)?; + respond(tx, handlers_state::initialize(params, &mut self.lsp_state, log_tx), LspResponse::Initialize)?; }, LspRequest::Shutdown => { out = LoopControl::Shutdown; respond(tx, Ok(()), LspResponse::Shutdown)?; }, LspRequest::DocumentFormatting(params) => { - respond(tx, handlers_format::document_formatting(params, &self.world), LspResponse::DocumentFormatting)?; + respond(tx, handlers_format::document_formatting(params, &self.lsp_state, &self.world), LspResponse::DocumentFormatting)?; }, LspRequest::DocumentRangeFormatting(params) => { respond(tx, handlers_format::document_range_formatting(params, &self.world), LspResponse::DocumentRangeFormatting)?; diff --git a/crates/lsp/src/state.rs b/crates/lsp/src/state.rs index 1bd38433..219a5165 100644 --- a/crates/lsp/src/state.rs +++ b/crates/lsp/src/state.rs @@ -14,9 +14,6 @@ pub(crate) struct WorldState { /// Watched documents pub(crate) documents: HashMap, - /// Watched folders - pub(crate) workspace: Workspace, - /// The scopes for the console. This currently contains a list (outer `Vec`) /// of names (inner `Vec`) within the environments on the search path, starting /// from the global environment and ending with the base package. Eventually @@ -46,11 +43,6 @@ pub(crate) struct WorldState { pub(crate) config: LspConfig, } -#[derive(Clone, Default, Debug)] -pub(crate) struct Workspace { - pub folders: Vec, -} - impl WorldState { pub(crate) fn get_document(&self, uri: &Url) -> anyhow::Result<&Document> { if let Some(doc) = self.documents.get(uri) { diff --git a/crates/lsp/src/tower_lsp.rs b/crates/lsp/src/tower_lsp.rs index ba114988..60de59d0 100644 --- a/crates/lsp/src/tower_lsp.rs +++ b/crates/lsp/src/tower_lsp.rs @@ -345,7 +345,7 @@ mod tests { text_document_sync, .. } => { - assert_eq!(position_encoding, None); + assert_eq!(position_encoding, Some(PositionEncodingKind::UTF16)); assert_eq!(text_document_sync, Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::INCREMENTAL))); }); diff --git a/crates/lsp/src/workspaces.rs b/crates/lsp/src/workspaces.rs new file mode 100644 index 00000000..1d64eae2 --- /dev/null +++ b/crates/lsp/src/workspaces.rs @@ -0,0 +1,174 @@ +use std::path::Path; +use std::path::PathBuf; + +use tower_lsp::lsp_types::WorkspaceFolder; +use url::Url; +use workspace::resolve::PathResolver; +use workspace::resolve::SettingsResolver; +use workspace::settings::Settings; + +/// Resolver for retrieving [`Settings`] associated with a workspace specific [`Path`] +#[derive(Debug, Default)] +pub(crate) struct WorkspaceSettingsResolver { + /// Resolves a `path` to the closest workspace specific `SettingsResolver`. + /// That `SettingsResolver` can then return `Settings` for the `path`. + path_to_settings_resolver: PathResolver, +} + +impl WorkspaceSettingsResolver { + /// Construct a new workspace settings resolver from an initial set of workspace folders + pub(crate) fn from_workspace_folders( + workspace_folders: Vec, + fallback: Settings, + ) -> Self { + let settings_resolver_fallback = SettingsResolver::new(fallback.clone()); + let path_to_settings_resolver = PathResolver::new(settings_resolver_fallback); + + let mut resolver = Self { + path_to_settings_resolver, + }; + + // Add each workspace folder's settings into the resolver. + // If we fail for any reason (i.e. parse failure of an `air.toml`) then + // we log an error and try to resolve the remaining workspace folders. We don't want + // to propagate an error here because we don't want to prevent the server from + // starting up entirely. + // TODO: This is one place it would be nice to show a toast notification back + // to the user, but we probably need to add support to the Aux thread for that? + for workspace_folder in workspace_folders { + if let Err(error) = + resolver.open_workspace_folder(&workspace_folder.uri, fallback.clone()) + { + tracing::error!( + "Failed to load workspace settings for '{uri}':\n{error}", + uri = workspace_folder.uri, + error = error + ); + } + } + + resolver + } + + pub(crate) fn open_workspace_folder( + &mut self, + url: &Url, + fallback: Settings, + ) -> anyhow::Result<()> { + let path = match Self::url_to_path(url)? { + Some(path) => path, + None => { + tracing::warn!("Ignoring non-file workspace URL: {url}"); + return Ok(()); + } + }; + + let mut settings_resolver = SettingsResolver::new(fallback); + settings_resolver.load_from_paths(&[&path])?; + + tracing::trace!("Adding workspace settings: {}", path.display()); + self.path_to_settings_resolver.add(&path, settings_resolver); + + Ok(()) + } + + pub(crate) fn close_workspace_folder( + &mut self, + url: &Url, + ) -> anyhow::Result> { + match Self::url_to_path(url)? { + Some(path) => { + tracing::trace!("Removing workspace settings: {}", path.display()); + Ok(self.path_to_settings_resolver.remove(&path)) + } + None => { + tracing::warn!("Ignoring non-file workspace URL: {url}"); + Ok(None) + } + } + } + + /// Return the appropriate [`Settings`] for a given document [`Url`]. + pub(crate) fn settings_for_url(&self, url: &Url) -> &Settings { + if let Ok(Some(path)) = Self::url_to_path(url) { + return self.settings_for_path(&path); + } + + // For `untitled` schemes, we have special behavior. + // If there is exactly 1 workspace, we resolve using a path of + // `{workspace_path}/untitled` to provide relevant settings for this workspace. + if url.scheme() == "untitled" && self.path_to_settings_resolver.len() == 1 { + tracing::trace!("Using workspace settings for 'untitled' URL: {url}"); + let workspace_path = self.path_to_settings_resolver.keys().next().unwrap(); + let path = workspace_path.join("untitled"); + return self.settings_for_path(&path); + } + + tracing::trace!("Using default settings for non-file URL: {url}"); + self.path_to_settings_resolver.fallback().fallback() + } + + /// Reloads all workspaces matched by the [`Url`] + /// + /// This is utilized by the watched files handler to reload the settings + /// resolver whenever an `air.toml` is modified. + pub(crate) fn reload_workspaces_matched_by_url(&mut self, url: &Url) { + let path = match Self::url_to_path(url) { + Ok(Some(path)) => path, + Ok(None) => { + tracing::trace!("Ignoring non-`file` changed URL: {url}"); + return; + } + Err(error) => { + tracing::error!("Failed to reload workspaces associated with {url}:\n{error}"); + return; + } + }; + + if !path.ends_with("air.toml") { + // We could get called with a changed file that isn't an `air.toml` if we are + // watching more than `air.toml` files + tracing::trace!("Ignoring non-`air.toml` changed URL: {url}"); + return; + } + + for (workspace_path, settings_resolver) in self.path_to_settings_resolver.matches_mut(&path) + { + tracing::trace!("Reloading workspace settings: {}", workspace_path.display()); + + settings_resolver.clear(); + + if let Err(error) = settings_resolver.load_from_paths(&[workspace_path]) { + tracing::error!( + "Failed to reload workspace settings for {path}:\n{error}", + path = workspace_path.display(), + error = error + ); + } + } + } + + /// Return the appropriate [`Settings`] for a given [`Path`]. + /// + /// This actually performs a double resolution. It first resolves to the + /// workspace specific `SettingsResolver` that matches this path, and then uses that + /// resolver to actually resolve the `Settings` for this path. We do it this way + /// to ensure we can easily add and remove workspaces (including all of their + /// hierarchical paths). + fn settings_for_path(&self, path: &Path) -> &Settings { + let settings_resolver = self.path_to_settings_resolver.resolve_or_fallback(path); + settings_resolver.resolve_or_fallback(path) + } + + fn url_to_path(url: &Url) -> anyhow::Result> { + if url.scheme() != "file" { + return Ok(None); + } + + let path = url + .to_file_path() + .map_err(|()| anyhow::anyhow!("Failed to convert workspace URL to file path: {url}"))?; + + Ok(Some(path)) + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 00000000..888fe4d6 --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "workspace" +version = "0.1.0" +publish = false +authors.workspace = true +categories.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +air_r_formatter = { workspace = true } +biome_formatter = { workspace = true, features = ["serde"] } +fs = { workspace = true } +ignore = { workspace = true } +line_ending = { workspace = true } +rustc-hash = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +insta = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs new file mode 100644 index 00000000..cb9835a0 --- /dev/null +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,4 @@ +pub mod resolve; +pub mod settings; +pub mod toml; +pub mod toml_options; diff --git a/crates/workspace/src/resolve.rs b/crates/workspace/src/resolve.rs new file mode 100644 index 00000000..c066e4e7 --- /dev/null +++ b/crates/workspace/src/resolve.rs @@ -0,0 +1,252 @@ +// --- source +// authors = ["Charlie Marsh"] +// license = "MIT" +// origin = "https://github.com/astral-sh/ruff/tree/main/crates/ruff_workspace" +// --- + +use std::collections::btree_map::Keys; +use std::collections::btree_map::Range; +use std::collections::btree_map::RangeMut; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use ignore::DirEntry; +use rustc_hash::FxHashSet; +use thiserror::Error; + +use crate::settings::Settings; +use crate::toml::find_air_toml_in_directory; +use crate::toml::parse_air_toml; +use crate::toml::ParseTomlError; + +/// Resolves a [`Path`] to its associated `T` +/// +/// To use a [`PathResolver`]: +/// - Load directories into it using [`PathResolver::add()`] +/// - Resolve a [`Path`] to its associated `T` with [`PathResolver::resolve()`] +/// +/// See [`PathResolver::resolve()`] for more details on the implementation. +#[derive(Debug, Default)] +pub struct PathResolver { + /// Fallback value to be used when a `path` isn't associated with anything in the `map` + fallback: T, + + /// An ordered `BTreeMap` from a `path` (normally, a directory) to a `T` + map: BTreeMap, +} + +impl PathResolver { + /// Create a new empty [`PathResolver`] + pub fn new(fallback: T) -> Self { + Self { + fallback, + map: BTreeMap::new(), + } + } + + pub fn fallback(&self) -> &T { + &self.fallback + } + + pub fn add(&mut self, path: &Path, value: T) -> Option { + self.map.insert(path.to_path_buf(), value) + } + + pub fn remove(&mut self, path: &Path) -> Option { + self.map.remove(path) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn keys(&self) -> Keys<'_, PathBuf, T> { + self.map.keys() + } + + pub fn clear(&mut self) { + self.map.clear(); + } + + /// Resolve a [`Path`] to its associated `T` + /// + /// This resolver works by finding the closest directory to the `path` to search for. + /// + /// The [`BTreeMap`] is an ordered map, so if you do: + /// + /// ```text + /// resolver.add("a/b", value1) + /// resolver.add("a/b/c", value2) + /// resolver.add("a/b/d", value3) + /// resolver.resolve("a/b/c/test.R") + /// ``` + /// + /// Then it detects both `"a/b"` and `"a/b/c"` as being "less than" the path of + /// `"a/b/c/test.R"`, and then chooses `"a/b/c"` because it is at the back of + /// that returned sorted list (i.e. the "closest" match). + pub fn resolve(&self, path: &Path) -> Option<&T> { + self.resolve_entry(path).map(|(_, value)| value) + } + + /// Same as `resolve()`, but returns the internal `fallback` if no associated value + /// is found. + pub fn resolve_or_fallback(&self, path: &Path) -> &T { + self.resolve(path).unwrap_or(self.fallback()) + } + + /// Same as `resolve()`, but returns the `(key, value)` pair. + /// + /// Useful when you need the matched workspace path + pub fn resolve_entry(&self, path: &Path) -> Option<(&PathBuf, &T)> { + self.matches(path).next_back() + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches(&self, path: &Path) -> Range<'_, PathBuf, T> { + self.map.range(..path.to_path_buf()) + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches_mut(&mut self, path: &Path) -> RangeMut<'_, PathBuf, T> { + self.map.range_mut(..path.to_path_buf()) + } +} + +pub type SettingsResolver = PathResolver; + +#[derive(Debug, Error)] +pub enum SettingsResolverError { + #[error(transparent)] + ParseToml(#[from] ParseTomlError), +} + +impl SettingsResolver { + /// This is the core function for walking a set of `paths` looking for `air.toml`s + /// and loading in any directories it finds + /// + /// For each `path`, we: + /// - Walk up its ancestors, looking for an `air.toml` + /// - TODO(hierarchical): Walk down its children, looking for nested `air.toml`s + /// + /// Whenever we find an `air.toml`, we add the directory it was found in and + /// the parsed [`Settings`] into the resolver. + pub fn load_from_paths>( + &mut self, + paths: &[P], + ) -> Result<(), SettingsResolverError> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let mut seen = FxHashSet::default(); + + // Load the `resolver` with `Settings` associated with each `path` + for path in &paths { + for ancestor in path.ancestors() { + if seen.insert(ancestor) { + if let Some(toml) = find_air_toml_in_directory(ancestor) { + let settings = Self::parse_settings(&toml)?; + self.add(ancestor, settings); + break; + } + } else { + // We already visited this ancestor, we can stop here. + break; + } + } + } + + // TODO(hierarchical): Also iterate through the directories and collect `air.toml` + // found nested withing the directories for hierarchical support + + Ok(()) + } + + /// Parse [Settings] from a given `air.toml` + // TODO(hierarchical): Allow for an `extends` option in `air.toml`, which will make things + // more complex, but will be very useful once we support hierarchical configuration as a + // way of "inheriting" most top level configuration while slightly tweaking it in a nested directory. + fn parse_settings(toml: &Path) -> Result { + let options = parse_air_toml(toml)?; + let settings = options.into_settings(); + Ok(settings) + } +} + +/// For each provided `path`, recursively search for any R files within that `path` +/// that match our inclusion criteria +/// +/// NOTE: Make sure that the inclusion criteria that guide `path` discovery are also +/// consistently applied to `settings_resolver_from_paths()`. +pub fn discover_r_file_paths>(paths: &[P]) -> Vec> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let Some((first_path, paths)) = paths.split_first() else { + // No paths provided + return Vec::new(); + }; + + // TODO: Parallel directory visitor + let mut builder = ignore::WalkBuilder::new(first_path); + + for path in paths { + builder.add(path); + } + + // TODO: Make these configurable options (possibly just one?) + // Right now we explicitly call them even though they are `true` by default + // to remind us to expose them. + // + // "This toggles, as a group, all the filters that are enabled by default" + // builder.standard_filters(true) + builder.hidden(true); + builder.parents(true); + builder.ignore(false); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + + let mut paths = Vec::new(); + + // Walk all `paths` recursively, collecting R files that we can format + for path in builder.build() { + match path { + Ok(entry) => { + if let Some(path) = is_match(entry) { + paths.push(Ok(path)); + } + } + Err(err) => { + paths.push(Err(err)); + } + } + } + + paths +} + +// Decide whether or not to accept an `entry` based on include/exclude rules. +fn is_match(entry: DirEntry) -> Option { + // Ignore directories + if entry.file_type().map_or(true, |ft| ft.is_dir()) { + return None; + } + + // Accept all files that are passed-in directly, even non-R files + if entry.depth() == 0 { + let path = entry.into_path(); + return Some(path); + } + + // Otherwise check if we should accept this entry + // TODO: Many other checks based on user exclude/includes + let path = entry.into_path(); + + if !fs::has_r_extension(&path) { + return None; + } + + Some(path) +} diff --git a/crates/workspace/src/settings.rs b/crates/workspace/src/settings.rs new file mode 100644 index 00000000..dec96df6 --- /dev/null +++ b/crates/workspace/src/settings.rs @@ -0,0 +1,60 @@ +mod indent_style; +mod indent_width; +// TODO: Can we pick a better crate name for `line_ending` so these don't collide? +#[path = "settings/line_ending.rs"] +mod line_ending_setting; +mod line_length; +mod magic_line_break; + +pub use indent_style::*; +pub use indent_width::*; +pub use line_ending_setting::*; +pub use line_length::*; +pub use magic_line_break::*; + +use air_r_formatter::context::RFormatOptions; +use line_ending; + +/// Resolved configuration settings used within air +/// +/// May still require a source document to finalize some options, such as +/// `LineEnding::Auto` in the formatter. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Settings { + /// Settings to configure code formatting. + pub format: FormatSettings, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct FormatSettings { + pub indent_style: IndentStyle, + pub indent_width: IndentWidth, + pub line_ending: LineEnding, + pub line_length: LineLength, + pub magic_line_break: MagicLineBreak, +} + +impl FormatSettings { + // Finalize `RFormatOptions` in preparation for a formatting operation on `source` + pub fn to_format_options(&self, source: &str) -> RFormatOptions { + let line_ending = match self.line_ending { + LineEnding::Lf => biome_formatter::LineEnding::Lf, + LineEnding::Crlf => biome_formatter::LineEnding::Crlf, + #[cfg(target_os = "windows")] + LineEnding::Native => biome_formatter::LineEnding::Crlf, + #[cfg(not(target_os = "windows"))] + LineEnding::Native => biome_formatter::LineEnding::Lf, + LineEnding::Auto => match line_ending::infer(source) { + line_ending::LineEnding::Lf => biome_formatter::LineEnding::Lf, + line_ending::LineEnding::Crlf => biome_formatter::LineEnding::Crlf, + }, + }; + + RFormatOptions::new() + .with_indent_style(self.indent_style.into()) + .with_indent_width(self.indent_width.into()) + .with_line_ending(line_ending) + .with_line_width(self.line_length.into()) + .with_magic_line_break(self.magic_line_break.into()) + } +} diff --git a/crates/workspace/src/settings/indent_style.rs b/crates/workspace/src/settings/indent_style.rs new file mode 100644 index 00000000..f84d94da --- /dev/null +++ b/crates/workspace/src/settings/indent_style.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IndentStyle { + /// Tab + #[default] + Tab, + /// Space + Space, +} + +impl IndentStyle { + /// Returns `true` if this is an [IndentStyle::Tab]. + pub const fn is_tab(&self) -> bool { + matches!(self, IndentStyle::Tab) + } + + /// Returns `true` if this is an [IndentStyle::Space]. + pub const fn is_space(&self) -> bool { + matches!(self, IndentStyle::Space) + } +} + +impl FromStr for IndentStyle { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "tab" => Ok(Self::Tab), + "space" => Ok(Self::Space), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for IndentStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IndentStyle::Tab => std::write!(f, "Tab"), + IndentStyle::Space => std::write!(f, "Space"), + } + } +} + +impl From for biome_formatter::IndentStyle { + fn from(value: IndentStyle) -> Self { + match value { + IndentStyle::Tab => biome_formatter::IndentStyle::Tab, + IndentStyle::Space => biome_formatter::IndentStyle::Space, + } + } +} diff --git a/crates/workspace/src/settings/indent_width.rs b/crates/workspace/src/settings/indent_width.rs new file mode 100644 index 00000000..f9717faa --- /dev/null +++ b/crates/workspace/src/settings/indent_width.rs @@ -0,0 +1,148 @@ +use std::fmt; +use std::num::NonZeroU8; + +/// Validated value for the `indent-width` formatter options +/// +/// The allowed range of values is 1..=24 +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct IndentWidth(NonZeroU8); + +impl IndentWidth { + /// Maximum allowed value for a valid [IndentWidth] + const MAX: u8 = 24; + + /// Return the numeric value for this [IndentWidth] + pub fn value(&self) -> u8 { + self.0.get() + } +} + +impl Default for IndentWidth { + fn default() -> Self { + Self(NonZeroU8::new(4).unwrap()) + } +} + +impl std::fmt::Debug for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl std::fmt::Display for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for IndentWidth { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u8 = serde::Deserialize::deserialize(deserializer)?; + let indent_width = IndentWidth::try_from(value).map_err(serde::de::Error::custom)?; + Ok(indent_width) + } +} + +/// Error type returned when converting a u8 or NonZeroU8 to a [`IndentWidth`] fails +#[derive(Clone, Copy, Debug)] +pub struct IndentWidthFromIntError(u8); + +impl std::error::Error for IndentWidthFromIntError {} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: u8) -> Result { + match NonZeroU8::try_from(value) { + Ok(value) => IndentWidth::try_from(value), + Err(_) => Err(IndentWidthFromIntError(value)), + } + } +} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: NonZeroU8) -> Result { + if value.get() <= Self::MAX { + Ok(IndentWidth(value)) + } else { + Err(IndentWidthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for IndentWidthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The indent width must be a value between 1 and {max}, not {value}.", + max = IndentWidth::MAX, + value = self.0 + ) + } +} + +impl From for u8 { + fn from(value: IndentWidth) -> Self { + value.0.get() + } +} + +impl From for NonZeroU8 { + fn from(value: IndentWidth) -> Self { + value.0 + } +} + +impl From for biome_formatter::IndentWidth { + fn from(value: IndentWidth) -> Self { + // Unwrap: We assert that we match biome's `IndentWidth` perfectly + biome_formatter::IndentWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::IndentWidth; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + indent_width: Option, + } + + #[test] + fn deserialize_indent_width() -> Result<()> { + let options: Options = toml::from_str( + r" +indent-width = 2 +", + )?; + + assert_eq!( + options.indent_width, + Some(IndentWidth::try_from(2).unwrap()) + ); + + Ok(()) + } + + #[test] + fn deserialize_oob_indent_width() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +indent-width = 25 +", + ); + let error = result.err().context("Expected OOB `IndentWidth` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/line_ending.rs b/crates/workspace/src/settings/line_ending.rs new file mode 100644 index 00000000..b2bcf870 --- /dev/null +++ b/crates/workspace/src/settings/line_ending.rs @@ -0,0 +1,31 @@ +use std::fmt; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LineEnding { + /// The newline style is detected automatically on a file per file basis. + /// Files with mixed line endings will be converted to the first detected line ending. + /// Defaults to [`LineEnding::Lf`] for a files that contain no line endings. + #[default] + Auto, + + /// Line endings will be converted to `\n` as is common on Unix. + Lf, + + /// Line endings will be converted to `\r\n` as is common on Windows. + Crlf, + + /// Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + Native, +} + +impl fmt::Display for LineEnding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Lf => write!(f, "lf"), + Self::Crlf => write!(f, "crlf"), + Self::Native => write!(f, "native"), + } + } +} diff --git a/crates/workspace/src/settings/line_length.rs b/crates/workspace/src/settings/line_length.rs new file mode 100644 index 00000000..934ef64d --- /dev/null +++ b/crates/workspace/src/settings/line_length.rs @@ -0,0 +1,145 @@ +use std::fmt; +use std::num::NonZeroU16; + +/// Validated value for the `line-length` formatter options +/// +/// The allowed range of values is 1..=320 +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct LineLength(NonZeroU16); + +impl LineLength { + /// Maximum allowed value for a valid [LineLength] + const MAX: u16 = 320; + + /// Return the numeric value for this [LineLength] + pub fn value(&self) -> u16 { + self.0.get() + } +} + +impl Default for LineLength { + fn default() -> Self { + Self(NonZeroU16::new(80).unwrap()) + } +} + +impl std::fmt::Debug for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for LineLength { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u16 = serde::Deserialize::deserialize(deserializer)?; + let line_length = LineLength::try_from(value).map_err(serde::de::Error::custom)?; + Ok(line_length) + } +} + +/// Error type returned when converting a u16 or NonZeroU16 to a [`LineLength`] fails +#[derive(Clone, Copy, Debug)] +pub struct LineLengthFromIntError(u16); + +impl std::error::Error for LineLengthFromIntError {} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: u16) -> Result { + match NonZeroU16::try_from(value) { + Ok(value) => LineLength::try_from(value), + Err(_) => Err(LineLengthFromIntError(value)), + } + } +} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: NonZeroU16) -> Result { + if value.get() <= Self::MAX { + Ok(LineLength(value)) + } else { + Err(LineLengthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for LineLengthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The line length must be a value between 1 and {max}, not {value}.", + max = LineLength::MAX, + value = self.0 + ) + } +} + +impl From for u16 { + fn from(value: LineLength) -> Self { + value.0.get() + } +} + +impl From for NonZeroU16 { + fn from(value: LineLength) -> Self { + value.0 + } +} + +impl From for biome_formatter::LineWidth { + fn from(value: LineLength) -> Self { + // Unwrap: We assert that we match biome's `LineWidth` perfectly + biome_formatter::LineWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::LineLength; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + line_length: Option, + } + + #[test] + fn deserialize_line_length() -> Result<()> { + let options: Options = toml::from_str( + r" +line-length = 50 +", + )?; + + assert_eq!(options.line_length, Some(LineLength::try_from(50).unwrap())); + + Ok(()) + } + + #[test] + fn deserialize_oob_line_length() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +line-length = 400 +", + ); + let error = result.err().context("Expected OOB `LineLength` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/magic_line_break.rs b/crates/workspace/src/settings/magic_line_break.rs new file mode 100644 index 00000000..e6d26d27 --- /dev/null +++ b/crates/workspace/src/settings/magic_line_break.rs @@ -0,0 +1,53 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} + +impl From for air_r_formatter::options::MagicLineBreak { + fn from(value: MagicLineBreak) -> Self { + match value { + MagicLineBreak::Respect => air_r_formatter::options::MagicLineBreak::Respect, + MagicLineBreak::Ignore => air_r_formatter::options::MagicLineBreak::Ignore, + } + } +} diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap new file mode 100644 index 00000000..dad86226 --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/indent_width.rs +expression: error +--- +TOML parse error at line 2, column 16 + | +2 | indent-width = 25 + | ^^ +The indent width must be a value between 1 and 24, not 25. diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap new file mode 100644 index 00000000..9570385c --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/line_length.rs +expression: error +--- +TOML parse error at line 2, column 15 + | +2 | line-length = 400 + | ^^^ +The line length must be a value between 1 and 320, not 400. diff --git a/crates/workspace/src/toml.rs b/crates/workspace/src/toml.rs new file mode 100644 index 00000000..98ffb238 --- /dev/null +++ b/crates/workspace/src/toml.rs @@ -0,0 +1,117 @@ +//! Utilities for locating (and extracting configuration from) an air.toml. + +use crate::toml_options::TomlOptions; +use std::fmt::Display; +use std::fmt::Formatter; +use std::io; +use std::path::{Path, PathBuf}; + +/// Parse an `air.toml` file. +pub fn parse_air_toml>(path: P) -> Result { + let contents = std::fs::read_to_string(path.as_ref()) + .map_err(|err| ParseTomlError::Read(path.as_ref().to_path_buf(), err))?; + + toml::from_str(&contents) + .map_err(|err| ParseTomlError::Deserialize(path.as_ref().to_path_buf(), err)) +} + +#[derive(Debug)] +pub enum ParseTomlError { + Read(PathBuf, io::Error), + Deserialize(PathBuf, toml::de::Error), +} + +impl std::error::Error for ParseTomlError {} + +impl Display for ParseTomlError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read(path, err) => { + write!( + f, + "Failed to read {path}:\n{err}", + path = fs::relativize_path(path), + ) + } + Self::Deserialize(path, err) => { + write!( + f, + "Failed to parse {path}:\n{err}", + path = fs::relativize_path(path), + ) + } + } + } +} + +/// Return the path to the `air.toml` file in a given directory. +pub fn find_air_toml_in_directory>(path: P) -> Option { + // Check for `air.toml`. + let toml = path.as_ref().join("air.toml"); + + if toml.is_file() { + Some(toml) + } else { + None + } +} + +/// Find the path to the closest `air.toml` if one exists, walking up the filesystem +pub fn find_air_toml>(path: P) -> Option { + for directory in path.as_ref().ancestors() { + if let Some(toml) = find_air_toml_in_directory(directory) { + return Some(toml); + } + } + None +} + +#[cfg(test)] +mod tests { + use anyhow::{Context, Result}; + use std::fs; + use tempfile::TempDir; + + use crate::settings::LineEnding; + use crate::toml::find_air_toml; + use crate::toml::parse_air_toml; + use crate::toml_options::TomlOptions; + + #[test] + + fn deserialize_empty() -> Result<()> { + let options: TomlOptions = toml::from_str(r"")?; + assert_eq!(options.global.indent_width, None); + assert_eq!(options.global.line_length, None); + assert_eq!(options.format, None); + Ok(()) + } + + #[test] + fn find_and_parse_air_toml() -> Result<()> { + let tempdir = TempDir::new()?; + let toml = tempdir.path().join("air.toml"); + fs::write( + toml, + r#" +line-length = 88 + +[format] +line-ending = "auto" +"#, + )?; + + let toml = find_air_toml(tempdir.path()).context("Failed to find air.toml")?; + let options = parse_air_toml(toml)?; + + let line_ending = options + .format + .context("Expected to find [format] table")? + .line_ending + .context("Expected to find `line-ending` field")?; + + assert_eq!(line_ending, LineEnding::Auto); + + Ok(()) + } +} diff --git a/crates/workspace/src/toml_options.rs b/crates/workspace/src/toml_options.rs new file mode 100644 index 00000000..fb397d6a --- /dev/null +++ b/crates/workspace/src/toml_options.rs @@ -0,0 +1,121 @@ +use crate::settings::FormatSettings; +use crate::settings::IndentStyle; +use crate::settings::IndentWidth; +use crate::settings::LineEnding; +use crate::settings::LineLength; +use crate::settings::MagicLineBreak; +use crate::settings::Settings; + +/// The Rust representation of `air.toml` +/// +/// The names and types of the fields in this struct determine the names and types +/// that can be specified in the `air.toml`. +/// +/// Every field is optional at this point, nothing is "finalized". +/// Finalization is done in [TomlOptions::into_settings]. +/// +/// Global options are specified at top level in the TOML file. +/// All other options are nested within their own `[table]`. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct TomlOptions { + /// Global options affecting multiple commands. + #[serde(flatten)] + pub global: GlobalTomlOptions, + + /// Options to configure code formatting. + pub format: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct GlobalTomlOptions { + /// The line length at which the formatter prefers to wrap lines. + /// + /// The value must be greater than or equal to `1` and less than or equal to `320`. + /// + /// Note: While the formatter will attempt to format lines such that they remain + /// within the `line-length`, it isn't a hard upper bound, and formatted lines may + /// exceed the `line-length`. + pub line_length: Option, + + /// The number of spaces per indentation level (tab). + /// + /// The value must be greater than or equal to `1` and less than or equal to `24`. + /// + /// Used by the formatter to determine the visual width of a tab. + /// + /// This option changes the number of spaces the formatter inserts when + /// using `indent-style = "space"`. It also represents the width of a tab when + /// `indent-style = "tab"` for the purposes of computing the `line-length`. + pub indent_width: Option, +} + +/// Configures the way air formats your code. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct FormatTomlOptions { + /// Whether to use spaces or tabs for indentation. + /// + /// `indent-style = "tab"` (default): + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # A tab `\t` indents the `cat()` call. + /// } + /// ``` + /// + /// `indent-style = "space"`: + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # Spaces indent the `cat()` call. + /// } + /// ``` + /// + /// We recommend you use tabs for accessibility. + /// + /// See `indent-width` to configure the number of spaces per indentation and the tab width. + pub indent_style: Option, + + /// The character air uses at the end of a line. + /// + /// * `auto`: The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to `\n` for files that contain no line endings. + /// * `lf`: Line endings will be converted to `\n`. The default line ending on Unix. + /// * `cr-lf`: Line endings will be converted to `\r\n`. The default line ending on Windows. + /// * `native`: Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + pub line_ending: Option, + + /// Air respects a small set of magic line breaks as an indication that certain + /// function calls or function signatures should be left expanded. If this option + /// is set to `true`, magic line breaks are ignored. + /// + /// It may be preferable to ignore magic line breaks if you prefer that `line-length` + /// should be the only value that influences line breaks. + pub ignore_magic_line_break: Option, +} + +impl TomlOptions { + pub fn into_settings(self) -> Settings { + let format = self.format.unwrap_or_default(); + + let format = FormatSettings { + indent_style: format.indent_style.unwrap_or_default(), + indent_width: self.global.indent_width.unwrap_or_default(), + line_ending: format.line_ending.unwrap_or_default(), + line_length: self.global.line_length.unwrap_or_default(), + magic_line_break: match format.ignore_magic_line_break { + Some(ignore_magic_line_break) => { + if ignore_magic_line_break { + MagicLineBreak::Ignore + } else { + MagicLineBreak::Respect + } + } + None => MagicLineBreak::Respect, + }, + }; + + Settings { format } + } +} diff --git a/editors/code/src/lsp.ts b/editors/code/src/lsp.ts index 8aede096..d35a788e 100644 --- a/editors/code/src/lsp.ts +++ b/editors/code/src/lsp.ts @@ -92,11 +92,6 @@ export class Lsp { { language: "r", pattern: "**/*.{r,R}" }, { language: "r", pattern: "**/*.{rprofile,Rprofile}" }, ], - synchronize: { - // Notify the server about file changes to R files contained in the workspace - fileEvents: - vscode.workspace.createFileSystemWatcher("**/*.[Rr]"), - }, outputChannel: this.channel, initializationOptions: initializationOptions, };