ruff server: Support Jupyter Notebook (*.ipynb) files (#11206)

## Summary

Closes https://github.com/astral-sh/ruff/issues/10858.

`ruff server` now supports `*.ipynb` (aka Jupyter Notebook) files.
Extensive internal changes have been made to facilitate this, which I've
done some work to contextualize with documentation and an pre-review
that highlights notable sections of the code.

`*.ipynb` cells should behave similarly to `*.py` documents, with one
major exception. The format command `ruff.applyFormat` will only apply
to the currently selected notebook cell - if you want to format an
entire notebook document, use `Format Notebook` from the VS Code context
menu.

## Test Plan

The VS Code extension does not yet have Jupyter Notebook support
enabled, so you'll first need to enable it manually. To do this,
checkout the `pre-release` branch and modify `src/common/server.ts` as
follows:

Before:
![Screenshot 2024-05-13 at 10 59
06 PM](c6a3c604-c405-4968-b8a2-5d670de89172)

After:
![Screenshot 2024-05-13 at 10 58
24 PM](94ab2e3d-0609-448d-9c8c-cd07c69a513b)

I recommend testing this PR with large, complicated notebook files. I
used notebook files from [this popular
repository](https://github.com/jakevdp/PythonDataScienceHandbook/tree/master/notebooks)
in my preliminary testing.

The main thing to test is ensuring that notebook cells behave the same
as Python documents, besides the aforementioned issue with
`ruff.applyFormat`. You should also test adding and deleting cells (in
particular, deleting all the code cells and ensure that doesn't break
anything), changing the kind of a cell (i.e. from markup -> code or vice
versa), and creating a new notebook file from scratch. Finally, you
should also test that source actions work as expected (and across the
entire notebook).

Note: `ruff.applyAutofix` and `ruff.applyOrganizeImports` are currently
broken for notebook files, and I suspect it has something to do with
https://github.com/astral-sh/ruff/issues/11248. Once this is fixed, I
will update the test plan accordingly.

---------

Co-authored-by: nolan <nolan.king90@gmail.com>
This commit is contained in:
Jane Lewis 2024-05-21 15:29:30 -07:00 committed by GitHub
parent 84531d1644
commit b0731ef9cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1584 additions and 622 deletions

View file

@ -0,0 +1,464 @@
use anyhow::anyhow;
use rustc_hash::FxHashMap;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
edit::{DocumentKey, DocumentVersion, NotebookDocument},
PositionEncoding, TextDocument,
};
use super::{
settings::{self, ResolvedClientSettings},
ClientSettings,
};
mod ruff_settings;
pub(crate) use ruff_settings::RuffSettings;
type DocumentIndex = FxHashMap<PathBuf, DocumentController>;
type NotebookCellIndex = FxHashMap<lsp_types::Url, PathBuf>;
type SettingsIndex = BTreeMap<PathBuf, WorkspaceSettings>;
/// Stores and tracks all open documents in a session, along with their associated settings.
#[derive(Default)]
pub(crate) struct Index {
/// Maps all document file paths to the associated document controller
documents: DocumentIndex,
/// Maps opaque cell URLs to a notebook path
notebook_cells: NotebookCellIndex,
/// Maps a workspace folder root to its settings.
settings: SettingsIndex,
}
/// Settings associated with a workspace.
struct WorkspaceSettings {
client_settings: ResolvedClientSettings,
workspace_settings_index: ruff_settings::RuffSettingsIndex,
}
/// A mutable handler to an underlying document.
enum DocumentController {
Text(Arc<TextDocument>),
Notebook(Arc<NotebookDocument>),
}
/// A read-only query to an open document.
/// This query can 'select' a text document, full notebook, or a specific notebook cell.
/// It also includes document settings.
#[derive(Clone)]
pub(crate) enum DocumentQuery {
Text {
file_path: PathBuf,
document: Arc<TextDocument>,
settings: Arc<RuffSettings>,
},
Notebook {
/// The selected notebook cell, if it exists.
cell_uri: Option<lsp_types::Url>,
file_path: PathBuf,
notebook: Arc<NotebookDocument>,
settings: Arc<RuffSettings>,
},
}
impl Index {
pub(super) fn new(
workspace_folders: Vec<(PathBuf, ClientSettings)>,
global_settings: &ClientSettings,
) -> Self {
let mut settings_index = BTreeMap::new();
for (path, workspace_settings) in workspace_folders {
Self::register_workspace_settings(
&mut settings_index,
path,
Some(workspace_settings),
global_settings,
);
}
Self {
documents: FxHashMap::default(),
notebook_cells: FxHashMap::default(),
settings: settings_index,
}
}
pub(super) fn update_text_document(
&mut self,
key: &DocumentKey,
content_changes: Vec<lsp_types::TextDocumentContentChangeEvent>,
new_version: DocumentVersion,
encoding: PositionEncoding,
) -> crate::Result<()> {
let controller = self.document_controller_for_key(key)?;
let Some(document) = controller.as_text_mut() else {
anyhow::bail!("Text document URI does not point to a text document");
};
if content_changes.is_empty() {
document.update_version(new_version);
return Ok(());
}
document.apply_changes(content_changes, new_version, encoding);
Ok(())
}
pub(super) fn key_from_url(&self, url: &lsp_types::Url) -> Option<DocumentKey> {
if self.notebook_cells.contains_key(url) {
return Some(DocumentKey::NotebookCell(url.clone()));
}
let path = url.to_file_path().ok()?;
Some(
match path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
{
"ipynb" => DocumentKey::Notebook(path),
_ => DocumentKey::Text(path),
},
)
}
pub(super) fn update_notebook_document(
&mut self,
key: &DocumentKey,
cells: Option<lsp_types::NotebookDocumentCellChange>,
metadata: Option<serde_json::Map<String, serde_json::Value>>,
new_version: DocumentVersion,
encoding: PositionEncoding,
) -> crate::Result<()> {
// update notebook cell index
if let Some(lsp_types::NotebookDocumentCellChangeStructure {
did_open,
did_close,
..
}) = cells.as_ref().and_then(|cells| cells.structure.as_ref())
{
let Some(path) = self.path_for_key(key).cloned() else {
anyhow::bail!("Tried to open unavailable document `{key}`");
};
for opened_cell in did_open.iter().flatten() {
self.notebook_cells
.insert(opened_cell.uri.clone(), path.clone());
}
for closed_cell in did_close.iter().flatten() {
if self.notebook_cells.remove(&closed_cell.uri).is_none() {
tracing::warn!(
"Tried to remove a notebook cell that does not exist: {}",
closed_cell.uri
);
}
}
}
let controller = self.document_controller_for_key(key)?;
let Some(notebook) = controller.as_notebook_mut() else {
anyhow::bail!("Notebook document URI does not point to a notebook document");
};
notebook.update(cells, metadata, new_version, encoding)?;
Ok(())
}
pub(super) fn open_workspace_folder(
&mut self,
path: PathBuf,
global_settings: &ClientSettings,
) {
// TODO(jane): Find a way for workspace client settings to be added or changed dynamically.
Self::register_workspace_settings(&mut self.settings, path, None, global_settings);
}
fn register_workspace_settings(
settings_index: &mut SettingsIndex,
workspace_path: PathBuf,
workspace_settings: Option<ClientSettings>,
global_settings: &ClientSettings,
) {
let client_settings = if let Some(workspace_settings) = workspace_settings {
ResolvedClientSettings::with_workspace(&workspace_settings, global_settings)
} else {
ResolvedClientSettings::global(global_settings)
};
let workspace_settings_index = ruff_settings::RuffSettingsIndex::new(
&workspace_path,
client_settings.editor_settings(),
);
settings_index.insert(
workspace_path,
WorkspaceSettings {
client_settings,
workspace_settings_index,
},
);
}
pub(super) fn close_workspace_folder(&mut self, workspace_path: &PathBuf) -> crate::Result<()> {
self.settings.remove(workspace_path).ok_or_else(|| {
anyhow!(
"Tried to remove non-existent folder {}",
workspace_path.display()
)
})?;
// O(n) complexity, which isn't ideal... but this is an uncommon operation.
self.documents
.retain(|path, _| !path.starts_with(workspace_path));
self.notebook_cells
.retain(|_, path| !path.starts_with(workspace_path));
Ok(())
}
pub(super) fn make_document_ref(&self, key: DocumentKey) -> Option<DocumentQuery> {
let path = self.path_for_key(&key)?.clone();
let document_settings = self
.settings_for_path(&path)?
.workspace_settings_index
.get(&path);
let controller = self.documents.get(&path)?;
let cell_uri = match key {
DocumentKey::NotebookCell(uri) => Some(uri),
_ => None,
};
Some(controller.make_ref(cell_uri, path, document_settings))
}
pub(super) fn reload_settings(&mut self, changed_path: &PathBuf) {
for (root, settings) in self
.settings
.iter_mut()
.filter(|(path, _)| path.starts_with(changed_path))
{
settings.workspace_settings_index = ruff_settings::RuffSettingsIndex::new(
root,
settings.client_settings.editor_settings(),
);
}
}
pub(super) fn open_text_document(&mut self, path: PathBuf, document: TextDocument) {
self.documents
.insert(path, DocumentController::new_text(document));
}
pub(super) fn open_notebook_document(&mut self, path: PathBuf, document: NotebookDocument) {
for url in document.urls() {
self.notebook_cells.insert(url.clone(), path.clone());
}
self.documents
.insert(path, DocumentController::new_notebook(document));
}
pub(super) fn close_document(&mut self, key: &DocumentKey) -> crate::Result<()> {
let Some(path) = self.path_for_key(key).cloned() else {
anyhow::bail!("Tried to open unavailable document `{key}`");
};
let Some(controller) = self.documents.remove(&path) else {
anyhow::bail!(
"tried to close document that didn't exist at {}",
path.display()
)
};
if let Some(notebook) = controller.as_notebook() {
for url in notebook.urls() {
self.notebook_cells.remove(url).ok_or_else(|| {
anyhow!("tried to de-register notebook cell with URL {url} that didn't exist")
})?;
}
}
Ok(())
}
pub(super) fn client_settings(
&self,
key: &DocumentKey,
global_settings: &ClientSettings,
) -> settings::ResolvedClientSettings {
let Some(path) = self.path_for_key(key) else {
return ResolvedClientSettings::global(global_settings);
};
let Some(WorkspaceSettings {
client_settings, ..
}) = self.settings_for_path(path)
else {
return ResolvedClientSettings::global(global_settings);
};
client_settings.clone()
}
fn document_controller_for_key(
&mut self,
key: &DocumentKey,
) -> crate::Result<&mut DocumentController> {
let Some(path) = self.path_for_key(key).cloned() else {
anyhow::bail!("Tried to open unavailable document `{key}`");
};
let Some(controller) = self.documents.get_mut(&path) else {
anyhow::bail!("Document controller not available at `{}`", path.display());
};
Ok(controller)
}
fn path_for_key<'a>(&'a self, key: &'a DocumentKey) -> Option<&'a PathBuf> {
match key {
DocumentKey::Notebook(path) | DocumentKey::Text(path) => Some(path),
DocumentKey::NotebookCell(uri) => self.notebook_cells.get(uri),
}
}
fn settings_for_path(&self, path: &Path) -> Option<&WorkspaceSettings> {
self.settings
.range(..path.to_path_buf())
.next_back()
.map(|(_, settings)| settings)
}
}
impl DocumentController {
fn new_text(document: TextDocument) -> Self {
Self::Text(Arc::new(document))
}
fn new_notebook(document: NotebookDocument) -> Self {
Self::Notebook(Arc::new(document))
}
fn make_ref(
&self,
cell_uri: Option<lsp_types::Url>,
file_path: PathBuf,
settings: Arc<RuffSettings>,
) -> DocumentQuery {
match &self {
Self::Notebook(notebook) => DocumentQuery::Notebook {
cell_uri,
file_path,
notebook: notebook.clone(),
settings,
},
Self::Text(document) => DocumentQuery::Text {
file_path,
document: document.clone(),
settings,
},
}
}
pub(crate) fn as_notebook_mut(&mut self) -> Option<&mut NotebookDocument> {
Some(match self {
Self::Notebook(notebook) => Arc::make_mut(notebook),
Self::Text(_) => return None,
})
}
pub(crate) fn as_notebook(&self) -> Option<&NotebookDocument> {
match self {
Self::Notebook(notebook) => Some(notebook),
Self::Text(_) => None,
}
}
#[allow(dead_code)]
pub(crate) fn as_text(&self) -> Option<&TextDocument> {
match self {
Self::Text(document) => Some(document),
Self::Notebook(_) => None,
}
}
pub(crate) fn as_text_mut(&mut self) -> Option<&mut TextDocument> {
Some(match self {
Self::Text(document) => Arc::make_mut(document),
Self::Notebook(_) => return None,
})
}
}
impl DocumentQuery {
/// Retrieve the original key that describes this document query.
pub(crate) fn make_key(&self) -> DocumentKey {
match self {
Self::Text { file_path, .. } => DocumentKey::Text(file_path.clone()),
Self::Notebook {
cell_uri: Some(cell_uri),
..
} => DocumentKey::NotebookCell(cell_uri.clone()),
Self::Notebook { file_path, .. } => DocumentKey::Notebook(file_path.clone()),
}
}
/// Get the document settings associated with this query.
pub(crate) fn settings(&self) -> &RuffSettings {
match self {
Self::Text { settings, .. } | Self::Notebook { settings, .. } => settings,
}
}
/// Generate a source kind used by the linter.
pub(crate) fn make_source_kind(&self) -> ruff_linter::source_kind::SourceKind {
match self {
Self::Text { document, .. } => {
ruff_linter::source_kind::SourceKind::Python(document.contents().to_string())
}
Self::Notebook { notebook, .. } => {
ruff_linter::source_kind::SourceKind::IpyNotebook(notebook.make_ruff_notebook())
}
}
}
/// Attempts to access the underlying notebook document that this query is selecting.
pub(crate) fn as_notebook(&self) -> Option<&NotebookDocument> {
match self {
Self::Notebook { notebook, .. } => Some(notebook),
Self::Text { .. } => None,
}
}
/// Get the source type of the document associated with this query.
pub(crate) fn source_type(&self) -> ruff_python_ast::PySourceType {
match self {
Self::Text { .. } => ruff_python_ast::PySourceType::Python,
Self::Notebook { .. } => ruff_python_ast::PySourceType::Ipynb,
}
}
/// Get the version of document selected by this query.
pub(crate) fn version(&self) -> DocumentVersion {
match self {
Self::Text { document, .. } => document.version(),
Self::Notebook { notebook, .. } => notebook.version(),
}
}
/// Get the underlying file path for the document selected by this query.
pub(crate) fn file_path(&self) -> &PathBuf {
match self {
Self::Text { file_path, .. } | Self::Notebook { file_path, .. } => file_path,
}
}
/// Attempt to access the single inner text document selected by the query.
/// If this query is selecting an entire notebook document, this will return `None`.
pub(crate) fn as_single_document(&self) -> Option<&TextDocument> {
match self {
Self::Text { document, .. } => Some(document),
Self::Notebook {
notebook, cell_uri, ..
} => cell_uri
.as_ref()
.and_then(|cell_uri| notebook.cell_document_by_uri(cell_uri)),
}
}
}

View file

@ -7,7 +7,7 @@ use serde::Deserialize;
use ruff_linter::{line_width::LineLength, RuleSelector};
/// Maps a workspace URI to its associated client settings. Used during server initialization.
pub(crate) type WorkspaceSettingsMap = FxHashMap<Url, ClientSettings>;
pub(crate) type WorkspaceSettingsMap = FxHashMap<PathBuf, ClientSettings>;
/// Resolved client settings for a specific document. These settings are meant to be
/// used directly by the server, and are *not* a 1:1 representation with how the client
@ -169,7 +169,12 @@ impl AllSettings {
workspace_settings: workspace_settings.map(|workspace_settings| {
workspace_settings
.into_iter()
.map(|settings| (settings.workspace, settings.settings))
.map(|settings| {
(
settings.workspace.to_file_path().unwrap(),
settings.settings,
)
})
.collect()
}),
}
@ -360,7 +365,7 @@ mod tests {
serde_json::from_str(content).expect("test fixture JSON should deserialize")
}
#[test]
#[cfg_attr(not(windows), test)]
fn test_vs_code_init_options_deserialize() {
let options: InitializationOptions = deserialize_fixture(VS_CODE_INIT_OPTIONS_FIXTURE);
@ -545,19 +550,19 @@ mod tests {
"###);
}
#[test]
#[cfg_attr(not(windows), test)]
fn test_vs_code_workspace_settings_resolve() {
let options = deserialize_fixture(VS_CODE_INIT_OPTIONS_FIXTURE);
let AllSettings {
global_settings,
workspace_settings,
} = AllSettings::from_init_options(options);
let url = Url::parse("file:///Users/test/projects/pandas").expect("url should parse");
let path = PathBuf::from_str("/Users/test/projects/pandas").expect("path should be valid");
let workspace_settings = workspace_settings.expect("workspace settings should exist");
assert_eq!(
ResolvedClientSettings::with_workspace(
workspace_settings
.get(&url)
.get(&path)
.expect("workspace setting should exist"),
&global_settings
),
@ -583,11 +588,11 @@ mod tests {
}
}
);
let url = Url::parse("file:///Users/test/projects/scipy").expect("url should parse");
let path = PathBuf::from_str("/Users/test/projects/scipy").expect("path should be valid");
assert_eq!(
ResolvedClientSettings::with_workspace(
workspace_settings
.get(&url)
.get(&path)
.expect("workspace setting should exist"),
&global_settings
),

View file

@ -1,269 +0,0 @@
use anyhow::anyhow;
use lsp_types::Url;
use rustc_hash::FxHashMap;
use std::{
collections::BTreeMap,
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{edit::DocumentVersion, Document};
use self::ruff_settings::RuffSettingsIndex;
use super::{
settings::{self, ResolvedClientSettings, ResolvedEditorSettings},
ClientSettings,
};
mod ruff_settings;
pub(crate) use ruff_settings::RuffSettings;
#[derive(Default)]
pub(crate) struct Workspaces(BTreeMap<PathBuf, Workspace>);
pub(crate) struct Workspace {
open_documents: OpenDocuments,
settings: ResolvedClientSettings,
}
pub(crate) struct OpenDocuments {
documents: FxHashMap<Url, DocumentController>,
settings_index: ruff_settings::RuffSettingsIndex,
}
/// A mutable handler to an underlying document.
/// Handles copy-on-write mutation automatically when
/// calling `deref_mut`.
pub(crate) struct DocumentController {
document: Arc<Document>,
}
/// A read-only reference to a document.
#[derive(Clone)]
pub(crate) struct DocumentRef {
document: Arc<Document>,
settings: Arc<RuffSettings>,
}
impl Workspaces {
pub(super) fn new(
workspaces: Vec<(Url, ClientSettings)>,
global_settings: &ClientSettings,
) -> crate::Result<Self> {
Ok(Self(
workspaces
.into_iter()
.map(|(url, workspace_settings)| {
Workspace::new(&url, &workspace_settings, global_settings)
})
.collect::<crate::Result<_>>()?,
))
}
pub(super) fn open_workspace_folder(
&mut self,
folder_url: &Url,
global_settings: &ClientSettings,
) -> crate::Result<()> {
// TODO(jane): find a way to allow for workspace settings to be updated dynamically
let (path, workspace) =
Workspace::new(folder_url, &ClientSettings::default(), global_settings)?;
self.0.insert(path, workspace);
Ok(())
}
pub(super) fn close_workspace_folder(&mut self, folder_url: &Url) -> crate::Result<()> {
let path = folder_url
.to_file_path()
.map_err(|()| anyhow!("Folder URI was not a proper file path"))?;
self.0
.remove(&path)
.ok_or_else(|| anyhow!("Tried to remove non-existent folder {}", path.display()))?;
Ok(())
}
pub(super) fn snapshot(&self, document_url: &Url) -> Option<DocumentRef> {
self.workspace_for_url(document_url)?
.open_documents
.snapshot(document_url)
}
pub(super) fn controller(&mut self, document_url: &Url) -> Option<&mut DocumentController> {
self.workspace_for_url_mut(document_url)?
.open_documents
.controller(document_url)
}
pub(super) fn reload_settings(&mut self, changed_url: &Url) -> crate::Result<()> {
let (root, workspace) = self
.entry_for_url_mut(changed_url)
.ok_or_else(|| anyhow!("Workspace not found for {changed_url}"))?;
workspace.reload_settings(root);
Ok(())
}
pub(super) fn open(&mut self, url: &Url, contents: String, version: DocumentVersion) {
if let Some(workspace) = self.workspace_for_url_mut(url) {
workspace.open_documents.open(url, contents, version);
}
}
pub(super) fn close(&mut self, url: &Url) -> crate::Result<()> {
self.workspace_for_url_mut(url)
.ok_or_else(|| anyhow!("Workspace not found for {url}"))?
.open_documents
.close(url)
}
pub(super) fn client_settings(
&self,
url: &Url,
global_settings: &ClientSettings,
) -> settings::ResolvedClientSettings {
self.workspace_for_url(url).map_or_else(
|| {
tracing::warn!(
"Workspace not found for {url}. Global settings will be used for this document"
);
settings::ResolvedClientSettings::global(global_settings)
},
|workspace| workspace.settings.clone(),
)
}
fn workspace_for_url(&self, url: &Url) -> Option<&Workspace> {
Some(self.entry_for_url(url)?.1)
}
fn workspace_for_url_mut(&mut self, url: &Url) -> Option<&mut Workspace> {
Some(self.entry_for_url_mut(url)?.1)
}
fn entry_for_url(&self, url: &Url) -> Option<(&Path, &Workspace)> {
let path = url.to_file_path().ok()?;
self.0
.range(..path)
.next_back()
.map(|(path, workspace)| (path.as_path(), workspace))
}
fn entry_for_url_mut(&mut self, url: &Url) -> Option<(&Path, &mut Workspace)> {
let path = url.to_file_path().ok()?;
self.0
.range_mut(..path)
.next_back()
.map(|(path, workspace)| (path.as_path(), workspace))
}
}
impl Workspace {
pub(crate) fn new(
root: &Url,
workspace_settings: &ClientSettings,
global_settings: &ClientSettings,
) -> crate::Result<(PathBuf, Self)> {
let path = root
.to_file_path()
.map_err(|()| anyhow!("workspace URL was not a file path!"))?;
let settings = ResolvedClientSettings::with_workspace(workspace_settings, global_settings);
let workspace = Self {
open_documents: OpenDocuments::new(&path, settings.editor_settings()),
settings,
};
Ok((path, workspace))
}
fn reload_settings(&mut self, root: &Path) {
self.open_documents
.reload_settings(root, self.settings.editor_settings());
}
}
impl OpenDocuments {
fn new(path: &Path, editor_settings: &ResolvedEditorSettings) -> Self {
Self {
documents: FxHashMap::default(),
settings_index: RuffSettingsIndex::new(path, editor_settings),
}
}
fn snapshot(&self, url: &Url) -> Option<DocumentRef> {
let path = url
.to_file_path()
.expect("document URL should convert to file path: {url}");
let document_settings = self.settings_index.get(&path);
Some(self.documents.get(url)?.make_ref(document_settings))
}
fn controller(&mut self, url: &Url) -> Option<&mut DocumentController> {
self.documents.get_mut(url)
}
fn open(&mut self, url: &Url, contents: String, version: DocumentVersion) {
if self
.documents
.insert(url.clone(), DocumentController::new(contents, version))
.is_some()
{
tracing::warn!("Opening document `{url}` that is already open!");
}
}
fn close(&mut self, url: &Url) -> crate::Result<()> {
let Some(_) = self.documents.remove(url) else {
return Err(anyhow!(
"Tried to close document `{url}`, which was not open"
));
};
Ok(())
}
fn reload_settings(&mut self, root: &Path, editor_settings: &ResolvedEditorSettings) {
self.settings_index = RuffSettingsIndex::new(root, editor_settings);
}
}
impl DocumentController {
fn new(contents: String, version: DocumentVersion) -> Self {
Self {
document: Arc::new(Document::new(contents, version)),
}
}
pub(crate) fn make_ref(&self, document_settings: Arc<RuffSettings>) -> DocumentRef {
DocumentRef {
document: self.document.clone(),
settings: document_settings,
}
}
pub(crate) fn make_mut(&mut self) -> &mut Document {
Arc::make_mut(&mut self.document)
}
}
impl Deref for DocumentController {
type Target = Document;
fn deref(&self) -> &Self::Target {
&self.document
}
}
impl Deref for DocumentRef {
type Target = Document;
fn deref(&self) -> &Self::Target {
&self.document
}
}
impl DocumentRef {
pub(crate) fn settings(&self) -> &RuffSettings {
&self.settings
}
}