From 6109952177751f0a78b804a49b1bccecec65290b Mon Sep 17 00:00:00 2001 From: David Sherret Date: Thu, 23 Nov 2023 18:45:21 -0500 Subject: [PATCH] Initial commit. --- .github/workflows/ci.yml | 125 ++++++++++++++++ .github/workflows/release.yml | 37 +++++ .gitignore | 5 + .rustfmt.toml | 3 + Cargo.toml | 36 +++++ LICENSE | 21 +++ README.md | 29 ++++ deployment/schema.json | 7 + dprint.json | 21 +++ rust-toolchain.toml | 3 + src/configuration/configuration.rs | 6 + src/configuration/mod.rs | 6 + src/configuration/resolve_config.rs | 41 ++++++ src/format_text.rs | 217 ++++++++++++++++++++++++++++ src/lib.rs | 13 ++ src/text_changes.rs | 177 +++++++++++++++++++++++ src/wasm_plugin.rs | 59 ++++++++ tests/specs/Basic.txt | 85 +++++++++++ tests/specs/MissingMetadata.txt | 75 ++++++++++ tests/test.rs | 55 +++++++ 20 files changed, 1021 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deployment/schema.json create mode 100644 dprint.json create mode 100644 rust-toolchain.toml create mode 100644 src/configuration/configuration.rs create mode 100644 src/configuration/mod.rs create mode 100644 src/configuration/resolve_config.rs create mode 100644 src/format_text.rs create mode 100644 src/lib.rs create mode 100644 src/text_changes.rs create mode 100644 src/wasm_plugin.rs create mode 100644 tests/specs/Basic.txt create mode 100644 tests/specs/MissingMetadata.txt create mode 100644 tests/test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7472023 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: CI + +on: [push, pull_request] + +jobs: + build: + name: ${{ matrix.config.kind }} ${{ matrix.config.os }} + runs-on: ${{ matrix.config.os }} + strategy: + matrix: + config: + - os: ubuntu-latest + kind: test_release + - os: ubuntu-latest + kind: test_debug + + env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: full + + steps: + - uses: actions/checkout@v3 + - uses: dsherret/rust-toolchain-file@v1 + - name: Install wasm32 target + if: matrix.config.kind == 'test_release' + run: rustup target add wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Build debug + if: matrix.config.kind == 'test_debug' + run: cargo build + - name: Build release + if: matrix.config.kind == 'test_release' + run: cargo build --target wasm32-unknown-unknown --features wasm --release + + - name: Test debug + if: matrix.config.kind == 'test_debug' + run: cargo test + - name: Test release + if: matrix.config.kind == 'test_release' + run: cargo test --release + + - name: Get tag version + if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + id: get_tag_version + run: echo ::set-output name=TAG_VERSION::${GITHUB_REF/refs\/tags\//} + + # todo: waiting on https://github.com/dprint/js-formatter/issues/2 + # NPM + # - uses: actions/setup-node@v2 + # if: matrix.config.kind == 'test_release' + # with: + # node-version: '18.x' + # registry-url: 'https://registry.npmjs.org' + + # - name: Setup and test npm deployment + # if: matrix.config.kind == 'test_release' + # run: | + # cd deployment/npm + # npm install + # node setup.js ${{ steps.get_tag_version.outputs.TAG_VERSION }} + # npm run test + + # - name: npm publish + # if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + # env: + # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # run: | + # cd deployment/npm + # npm publish --access public + # git reset --hard + + # CARGO PUBLISH + - name: Cargo login + if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + run: cargo login ${{ secrets.CRATES_TOKEN }} + + - name: Cargo publish + if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + run: cargo publish + + # GITHUB RELEASE + - name: Pre-release + if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + run: | + # update config schema to have version + sed -i 's/jupyter\/0.0.0/jupyter\/${{ steps.get_tag_version.outputs.TAG_VERSION }}/' deployment/schema.json + # rename the wasm file + (cd target/wasm32-unknown-unknown/release/ && mv dprint_plugin_jupyter.wasm plugin.wasm) + - name: Release + uses: softprops/action-gh-release@v1 + if: matrix.config.kind == 'test_release' && startsWith(github.ref, 'refs/tags/') + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: | + target/wasm32-unknown-unknown/release/plugin.wasm + deployment/schema.json + body: | + ## Install + + [Install](https://dprint.dev/install/) and [setup](https://dprint.dev/setup/) dprint. + + Then in your project's directory with a dprint.json file, run: + + ```shellsession + dprint config add jupyter + ``` + + Then add some additional formatting plugins to format the code blocks with. For example: + + ```shellsession + dprint config add typescript + dprint config add markdown + dprint config add ruff + ``` + + # ## JS Formatting API + + # * [JS Formatter](https://github.com/dprint/js-formatter) - Browser/Deno and Node + # * [npm package](https://www.npmjs.com/package/@dprint/jupyter) + draft: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af0d33d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: release + +on: + workflow_dispatch: + inputs: + releaseKind: + description: 'Kind of release' + default: 'minor' + type: choice + options: + - patch + - minor + required: true + +jobs: + rust: + name: release + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Clone repository + uses: actions/checkout@v3 + with: + token: ${{ secrets.GH_DPRINTBOT_PAT }} + + - uses: denoland/setup-deno@v1 + - uses: dsherret/rust-toolchain-file@v1 + + - name: Bump version and tag + env: + GITHUB_TOKEN: ${{ secrets.GH_DPRINTBOT_PAT }} + GH_WORKFLOW_ACTOR: ${{ github.actor }} + run: | + git config user.email "${{ github.actor }}@users.noreply.github.com" + git config user.name "${{ github.actor }}" + deno run -A https://raw.githubusercontent.com/dprint/automation/0.8.0/tasks/publish_release.ts --${{github.event.inputs.releaseKind}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6795074 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +Cargo.lock +deployment/npm/buffer.generated.js +deployment/npm/node_modules +.vscode diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..804c215 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,3 @@ +max_width = 120 +tab_spaces = 2 +edition = "2021" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..baaa9ae --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "dprint-plugin-jupyter" +version = "0.0.1" +authors = ["David Sherret "] +edition = "2021" +homepage = "https://github.com/dprint/dprint-plugin-jupyter" +keywords = ["formatting", "formatter", "jupyter"] +license = "MIT" +repository = "https://github.com/dprint/dprint-plugin-jupyter" +description = "Formats code blocks in Jupyter notebooks." + +[lib] +crate-type = ["lib", "cdylib"] + +[profile.release] +opt-level = 3 +debug = false +lto = true +debug-assertions = false +overflow-checks = false +panic = "abort" + +[features] +wasm = ["dprint-core/wasm"] + +[dependencies] +anyhow = "1.0.51" +dprint-core = { version = "0.63.3", features = ["formatting"] } +jsonc-parser = "0.23.0" +serde = { version = "1.0.108", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +dprint-development = "0.9.5" +pretty_assertions = "1.4.0" +serde_json = { version = "1.0" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67fb2d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 David Sherret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3f468e --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# dprint-plugin-jupyter + +[![](https://img.shields.io/crates/v/dprint-plugin-jupyter.svg)](https://crates.io/crates/dprint-plugin-jupyter) [![CI](https://github.com/dprint/dprint-plugin-jupyter/workflows/CI/badge.svg)](https://github.com/dprint/dprint-plugin-jupyter/actions?query=workflow%3ACI) + +Formats code blocks in Jupyter notebook files (`.ipynb`) using dprint plugins. + +## Install + +[Install](https://dprint.dev/install/) and [setup](https://dprint.dev/setup/) dprint. + +Then in your project's directory with a dprint.json file, run: + +```shellsession +dprint config add jupyter +``` + +Then add some additional formatting plugins to format the code blocks with. For example: + +```shellsession +dprint config add typescript +dprint config add markdown +dprint config add ruff +``` + +If you find a code block isn't being formatted with a plugin, please verify it's not a syntax error, then please open an [issue](https://github.com/dprint/dprint-plugin-jupyter/issues) about adding support for that plugin (if you're interested in submitting a PR, it's potentially an easy contribution). + +## Configuration + +Configuration is handled in other plugins. diff --git a/deployment/schema.json b/deployment/schema.json new file mode 100644 index 0000000..368e35d --- /dev/null +++ b/deployment/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://plugins.dprint.dev/dprint/dprint-plugin-jupyter/0.0.0/schema.json", + "type": "object", + "properties": { + } +} diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..6a50b55 --- /dev/null +++ b/dprint.json @@ -0,0 +1,21 @@ +{ + "exec": { + "commands": [{ + "command": "rustfmt", + "exts": ["rs"] + }] + }, + "excludes": [ + "**/node_modules", + "**/*-lock.json", + "deployment/npm/buffer.generated.js", + "**/target" + ], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.88.4.wasm", + "https://plugins.dprint.dev/json-0.19.0.wasm", + "https://plugins.dprint.dev/markdown-0.16.2.wasm", + "https://plugins.dprint.dev/toml-0.5.4.wasm", + "https://plugins.dprint.dev/exec-0.4.4.json@c207bf9b9a4ee1f0ecb75c594f774924baf62e8e53a2ce9d873816a408cecbf7" + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7737e53 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.74.0" +components = ["clippy", "rustfmt"] diff --git a/src/configuration/configuration.rs b/src/configuration/configuration.rs new file mode 100644 index 0000000..ef0882a --- /dev/null +++ b/src/configuration/configuration.rs @@ -0,0 +1,6 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Configuration {} diff --git a/src/configuration/mod.rs b/src/configuration/mod.rs new file mode 100644 index 0000000..55cca33 --- /dev/null +++ b/src/configuration/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod configuration; +mod resolve_config; + +pub use configuration::*; +pub use resolve_config::*; diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs new file mode 100644 index 0000000..db762c4 --- /dev/null +++ b/src/configuration/resolve_config.rs @@ -0,0 +1,41 @@ +use super::Configuration; +use dprint_core::configuration::*; + +/// Resolves configuration from a collection of key value strings. +/// +/// # Example +/// +/// ``` +/// use dprint_core::configuration::ConfigKeyMap; +/// use dprint_core::configuration::resolve_global_config; +/// use dprint_plugin_jupyter::configuration::resolve_config; +/// +/// let mut config_map = ConfigKeyMap::new(); // get a collection of key value pairs from somewhere +/// let global_config_result = resolve_global_config(&mut config_map); +/// +/// // check global_config_result.diagnostics here... +/// +/// let config_result = resolve_config( +/// config_map, +/// &global_config_result.config +/// ); +/// +/// // check config_result.diagnostics here and use config_result.config +/// ``` +pub fn resolve_config( + config: ConfigKeyMap, + _global_config: &GlobalConfiguration, +) -> ResolveConfigurationResult { + let mut diagnostics = Vec::new(); + + let resolved_config = Configuration { + // todo... + }; + + diagnostics.extend(get_unknown_property_diagnostics(config)); + + ResolveConfigurationResult { + config: resolved_config, + diagnostics, + } +} diff --git a/src/format_text.rs b/src/format_text.rs new file mode 100644 index 0000000..1172e4b --- /dev/null +++ b/src/format_text.rs @@ -0,0 +1,217 @@ +use std::borrow::Cow; +use std::path::Path; +use std::path::PathBuf; + +use crate::configuration::Configuration; +use crate::text_changes::apply_text_changes; +use crate::text_changes::TextChange; +use anyhow::Result; +use dprint_core::configuration::ConfigKeyMap; +use jsonc_parser::CollectOptions; +use jsonc_parser::ParseOptions; + +pub fn format_text( + _file_path: &Path, + input_text: &str, + _config: &Configuration, + format_with_host: impl FnMut(&Path, String, &ConfigKeyMap) -> Result>, +) -> Result> { + let parse_result = jsonc_parser::parse_to_ast( + input_text, + &CollectOptions { + comments: false, + tokens: false, + }, + &ParseOptions { + allow_comments: true, + allow_loose_object_property_names: true, + allow_trailing_commas: true, + }, + )?; + let Some(root_value) = parse_result.value else { + return Ok(None); + }; + + Ok(format_root(input_text, &root_value, format_with_host)) +} + +fn format_root( + input_text: &str, + root_value: &jsonc_parser::ast::Value, + mut format_with_host: impl FnMut(&Path, String, &ConfigKeyMap) -> Result>, +) -> Option { + let root_obj = root_value.as_object()?; + let maybe_default_language = get_metadata_language(root_obj); + let cells = root_value.as_object()?.get_array("cells")?; + let mut text_changes = Vec::new(); + for element in &cells.elements { + let maybe_text_change = get_cell_text_change(input_text, element, maybe_default_language, &mut format_with_host); + if let Some(text_change) = maybe_text_change { + text_changes.push(text_change); + } + } + if text_changes.is_empty() { + None + } else { + Some(apply_text_changes(input_text, text_changes)) + } +} + +fn get_cell_text_change( + file_text: &str, + cell: &jsonc_parser::ast::Value, + maybe_default_language: Option<&str>, + format_with_host: &mut impl FnMut(&Path, String, &ConfigKeyMap) -> Result>, +) -> Option { + let cell = cell.as_object()?; + let cell_language = get_cell_vscode_language_id(cell).or_else(|| { + let cell_type = cell.get_string("cell_type")?; + match cell_type.value.as_ref() { + "markdown" => Some("markdown"), + "code" => maybe_default_language, + _ => None, + } + })?; + let code_block = analyze_code_block(cell, file_text)?; + let file_path = language_to_path(cell_language)?; + let formatted_text = format_with_host(&file_path, code_block.source, &ConfigKeyMap::new()).ok()??; + // many plugins will add a final newline, but that doesn't look nice in notebooks, so trim it off + let formatted_text = formatted_text.trim_end(); + + let new_text = build_json_text(formatted_text, code_block.indent_text); + + Some(TextChange { + range: code_block.replace_range, + new_text, + }) +} + +struct CodeBlockText<'a> { + indent_text: &'a str, + replace_range: std::ops::Range, + source: String, +} + +fn analyze_code_block<'a>(cell: &jsonc_parser::ast::Object<'a>, file_text: &'a str) -> Option> { + let mut indent_text = ""; + let mut replace_range = std::ops::Range::default(); + let cell_source = match &cell.get("source")?.value { + jsonc_parser::ast::Value::Array(items) => { + let mut strings = Vec::with_capacity(items.elements.len()); + for (i, element) in items.elements.iter().enumerate() { + let string_lit = element.as_string_lit()?; + if i == 0 { + indent_text = get_indent_text(file_text, string_lit.range.start); + replace_range.start = string_lit.range.start; + } + if i == items.elements.len() - 1 { + replace_range.end = string_lit.range.end; + } + strings.push(&string_lit.value); + } + let mut text = String::with_capacity(strings.iter().map(|s| s.len()).sum::()); + for string in strings { + text.push_str(string); + } + text + } + jsonc_parser::ast::Value::StringLit(string) => string.value.to_string(), + _ => return None, + }; + Some(CodeBlockText { + indent_text, + replace_range, + source: cell_source, + }) +} + +/// Turn the formatted text into a json array, split up by line breaks. +fn build_json_text(formatted_text: &str, indent_text: &str) -> String { + let mut new_text = String::new(); + let mut current_end_index = 0; + for (i, line) in formatted_text.split('\n').enumerate() { + current_end_index += line.len(); + if i > 0 { + new_text.push_str(",\n"); + new_text.push_str(indent_text); + } + let is_last_line = current_end_index == formatted_text.len(); + new_text.push_str( + &serde_json::to_string( + if is_last_line { + Cow::Borrowed(line) + } else { + Cow::Owned(format!("{}\n", line)) + } + .as_ref(), + ) + .unwrap(), + ); + current_end_index += 1; + } + new_text +} + +fn get_metadata_language<'a>(root_obj: &'a jsonc_parser::ast::Object<'a>) -> Option<&'a str> { + let language_info = root_obj.get_object("metadata")?.get_object("language_info")?; + Some(&language_info.get_string("name")?.value) +} + +fn get_cell_vscode_language_id<'a>(cell: &'a jsonc_parser::ast::Object<'a>) -> Option<&'a str> { + let cell_metadata = cell.get_object("metadata")?; + let cell_language_info = cell_metadata.get_object("vscode")?; + Some(&cell_language_info.get_string("languageId")?.value) +} + +fn language_to_path(language: &str) -> Option { + let ext = match language.to_lowercase().as_str() { + "bash" => Some("sh"), + "c++" => Some("cpp"), + "css" => Some("css"), + "csharp" => Some("cs"), + "html" => Some("html"), + "go" => Some("go"), + "kotlin" => Some("kt"), + "json" => Some("json"), + "julia" => Some("jl"), + "markdown" => Some("md"), + "typescript" => Some("ts"), + "javascript" => Some("js"), + "perl" => Some("perl"), + "php" => Some("php"), + "python" | "python3" => Some("py"), + "r" => Some("r"), + "ruby" => Some("rb"), + "scala" => Some("scala"), + "sql" => Some("sql"), + "yaml" => Some("yml"), + _ => None, + }; + ext.map(|ext| PathBuf::from(format!("code_block.{}", ext))) +} + +fn get_indent_text(file_text: &str, start_pos: usize) -> &str { + let preceeding_text = &file_text[..start_pos]; + let whitespace_start = preceeding_text.trim_end().len(); + let whitespace_text = &preceeding_text[whitespace_start..]; + let whitespace_newline_pos = whitespace_text.rfind('\n'); + &preceeding_text[whitespace_newline_pos + .map(|pos| whitespace_start + pos + 1) + .unwrap_or(whitespace_start)..] +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_get_indent_text() { + assert_eq!(get_indent_text(" hello", 2), " "); + assert_eq!(get_indent_text("\n hello", 3), " "); + assert_eq!(get_indent_text("t\n hello", 4), " "); + assert_eq!(get_indent_text("t\n\t\thello", 4), "\t\t"); + assert_eq!(get_indent_text("hello", 0), ""); + assert_eq!(get_indent_text("\nhello", 1), ""); + assert_eq!(get_indent_text("\nhello", 2), ""); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..97022f8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +pub mod configuration; +mod format_text; +mod text_changes; + +pub use format_text::format_text; + +#[cfg(feature = "wasm")] +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +mod wasm_plugin; + +#[cfg(feature = "wasm")] +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use wasm_plugin::*; diff --git a/src/text_changes.rs b/src/text_changes.rs new file mode 100644 index 0000000..e9ebedd --- /dev/null +++ b/src/text_changes.rs @@ -0,0 +1,177 @@ +// Lifted from code I wrote at https://github.com/denoland/deno_ast/blob/637ef2920f187da2d15799e6aa1c6d7b236a1c84/src/text_changes.rs +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use std::cmp::Ordering; +use std::ops::Range; + +#[derive(Clone, Debug)] +pub struct TextChange { + /// Range start to end byte index. + pub range: Range, + /// New text to insert or replace at the provided range. + pub new_text: String, +} + +#[cfg(test)] +impl TextChange { + pub fn new(start: usize, end: usize, new_text: String) -> Self { + Self { + range: start..end, + new_text, + } + } +} + +/// Applies the text changes to the given source text. +pub fn apply_text_changes(source: &str, mut changes: Vec) -> String { + changes.sort_by(|a, b| match a.range.start.cmp(&b.range.start) { + Ordering::Equal => a.range.end.cmp(&b.range.end), + ordering => ordering, + }); + + let mut last_index = 0; + let mut final_text = String::new(); + + for (i, change) in changes.iter().enumerate() { + if change.range.start > change.range.end { + panic!( + "Text change had start index {} greater than end index {}.\n\n{:?}", + change.range.start, + change.range.end, + &changes[0..i + 1], + ) + } + if change.range.start < last_index { + panic!( + "Text changes were overlapping. Past index was {}, but new change had index {}.\n\n{:?}", + last_index, + change.range.start, + &changes[0..i + 1] + ); + } else if change.range.start > last_index && last_index < source.len() { + final_text.push_str(&source[last_index..std::cmp::min(source.len(), change.range.start)]); + } + final_text.push_str(&change.new_text); + last_index = change.range.end; + } + + if last_index < source.len() { + final_text.push_str(&source[last_index..]); + } + + final_text +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn applies_text_changes() { + // replacing text + assert_eq!( + apply_text_changes( + "0123456789", + vec![ + TextChange::new(9, 10, "z".to_string()), + TextChange::new(4, 6, "y".to_string()), + TextChange::new(1, 2, "x".to_string()), + ] + ), + "0x23y678z".to_string(), + ); + + // replacing beside + assert_eq!( + apply_text_changes( + "0123456789", + vec![ + TextChange::new(0, 5, "a".to_string()), + TextChange::new(5, 7, "b".to_string()), + TextChange::new(7, 10, "c".to_string()), + ] + ), + "abc".to_string(), + ); + + // full replace + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(0, 10, "x".to_string()),]), + "x".to_string(), + ); + + // 1 over + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(0, 11, "x".to_string()),]), + "x".to_string(), + ); + + // insert + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(5, 5, "x".to_string()),]), + "01234x56789".to_string(), + ); + + // prepend + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(0, 0, "x".to_string()),]), + "x0123456789".to_string(), + ); + + // append + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(10, 10, "x".to_string()),]), + "0123456789x".to_string(), + ); + + // append over + assert_eq!( + apply_text_changes("0123456789", vec![TextChange::new(11, 11, "x".to_string()),]), + "0123456789x".to_string(), + ); + + // multiple at start + assert_eq!( + apply_text_changes( + "0123456789", + vec![ + TextChange::new(0, 7, "a".to_string()), + TextChange::new(0, 0, "b".to_string()), + TextChange::new(0, 0, "c".to_string()), + TextChange::new(7, 10, "d".to_string()), + ] + ), + "bcad".to_string(), + ); + } + + #[test] + #[should_panic(expected = "Text changes were overlapping. Past index was 10, but new change had index 5.")] + fn panics_text_change_within() { + apply_text_changes( + "0123456789", + vec![ + TextChange::new(3, 10, "x".to_string()), + TextChange::new(5, 7, "x".to_string()), + ], + ); + } + + #[test] + #[should_panic(expected = "Text changes were overlapping. Past index was 4, but new change had index 3.")] + fn panics_text_change_overlap() { + apply_text_changes( + "0123456789", + vec![ + TextChange::new(2, 4, "x".to_string()), + TextChange::new(3, 5, "x".to_string()), + ], + ); + } + + #[test] + #[should_panic(expected = "Text change had start index 2 greater than end index 1.")] + fn panics_start_greater_end() { + apply_text_changes("0123456789", vec![TextChange::new(2, 1, "x".to_string())]); + } +} diff --git a/src/wasm_plugin.rs b/src/wasm_plugin.rs new file mode 100644 index 0000000..0326d6c --- /dev/null +++ b/src/wasm_plugin.rs @@ -0,0 +1,59 @@ +use super::configuration::{resolve_config, Configuration}; + +use dprint_core::configuration::{ConfigKeyMap, GlobalConfiguration, ResolveConfigurationResult}; +use dprint_core::generate_plugin_code; +use dprint_core::plugins::FileMatchingInfo; +use dprint_core::plugins::FormatResult; +use dprint_core::plugins::PluginInfo; +use dprint_core::plugins::SyncPluginHandler; +use dprint_core::plugins::SyncPluginInfo; +use std::path::Path; + +struct JupyterPluginHandler; + +impl SyncPluginHandler for JupyterPluginHandler { + fn resolve_config( + &mut self, + config: ConfigKeyMap, + global_config: &GlobalConfiguration, + ) -> ResolveConfigurationResult { + resolve_config(config, global_config) + } + + fn plugin_info(&mut self) -> SyncPluginInfo { + let version = env!("CARGO_PKG_VERSION").to_string(); + SyncPluginInfo { + info: PluginInfo { + name: env!("CARGO_PKG_NAME").to_string(), + version: version.clone(), + config_key: "jupyter".to_string(), + help_url: "https://dprint.dev/plugins/jupyter".to_string(), + config_schema_url: format!( + "https://plugins.dprint.dev/dprint/dprint-plugin-jupyter/{}/schema.json", + version + ), + update_url: Some("https://plugins.dprint.dev/dprint/dprint-plugin-jupyter/latest.json".to_string()), + }, + file_matching: FileMatchingInfo { + file_extensions: vec!["ipynb".to_string()], + file_names: vec![], + }, + } + } + + fn license_text(&mut self) -> String { + std::str::from_utf8(include_bytes!("../LICENSE")).unwrap().into() + } + + fn format( + &mut self, + file_path: &Path, + file_text: &str, + config: &Configuration, + format_with_host: impl FnMut(&Path, String, &ConfigKeyMap) -> FormatResult, + ) -> FormatResult { + super::format_text(file_path, file_text, config, format_with_host) + } +} + +generate_plugin_code!(JupyterPluginHandler, JupyterPluginHandler); diff --git a/tests/specs/Basic.txt b/tests/specs/Basic.txt new file mode 100644 index 0000000..492affa --- /dev/null +++ b/tests/specs/Basic.txt @@ -0,0 +1,85 @@ +== should format == +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "typescript" + } + }, + "outputs": [], + "source": [ + "code block 1" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "code block 2\n", + "\n", + "next line", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "code block 3" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} + +[expect] +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "typescript" + } + }, + "outputs": [], + "source": [ + "code block 1_typescript" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "code block 2\n", + "\n", + "next line_markdown", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "code block 3_python" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/specs/MissingMetadata.txt b/tests/specs/MissingMetadata.txt new file mode 100644 index 0000000..7812401 --- /dev/null +++ b/tests/specs/MissingMetadata.txt @@ -0,0 +1,75 @@ +== should format == +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "typescript" + } + }, + "outputs": [], + "source": [ + "code block 1" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "code block 2\n", + "\n", + "next line", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "code block 3" + ] + } + ], + "nbformat": 4, + "nbformat_minor": 2 +} + +[expect] +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "typescript" + } + }, + "outputs": [], + "source": [ + "code block 1_typescript" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "code block 2\n", + "\n", + "next line_markdown", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "code block 3" + ] + } + ], + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..5c30a5b --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,55 @@ +use std::path::PathBuf; + +use dprint_core::configuration::*; +use dprint_development::*; +use dprint_plugin_jupyter::configuration::resolve_config; +use dprint_plugin_jupyter::*; + +#[test] +fn test_specs() { + let global_config = GlobalConfiguration::default(); + + run_specs( + &PathBuf::from("./tests/specs"), + &ParseSpecOptions { + default_file_name: "file.py", + }, + &RunSpecsOptions { + fix_failures: false, + format_twice: true, + }, + { + let global_config = global_config.clone(); + move |file_path, file_text, spec_config| { + let spec_config: ConfigKeyMap = serde_json::from_value(spec_config.clone().into()).unwrap(); + let config_result = resolve_config(spec_config, &global_config); + ensure_no_diagnostics(&config_result.diagnostics); + + format_text(file_path, &file_text, &config_result.config, |path, text, _config| { + if path.ends_with("code_block.py") { + if !text.ends_with("_python") { + Ok(Some(format!("{}_python", text))) + } else { + Ok(None) + } + } else if path.ends_with("code_block.md") { + if !text.ends_with("_markdown") { + Ok(Some(format!("{}_markdown", text))) + } else { + Ok(None) + } + } else if path.ends_with("code_block.ts") { + if !text.ends_with("_typescript") { + Ok(Some(format!("{}_typescript", text))) + } else { + Ok(None) + } + } else { + Ok(None) + } + }) + } + }, + move |_file_path, _file_text, _spec_config| panic!("Plugin does not support dprint-core tracing."), + ) +}