From 2968c1781914c20e3a6ff38c9317b7031c32fba8 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sun, 28 Jul 2024 09:24:13 -0400 Subject: [PATCH] Add syntax highlight via tree-sitter-highlight Closes #264 --- CHANGELOG.md | 4 + Cargo.lock | 35 ++ Cargo.toml | 1 + crates/slumber_core/Cargo.toml | 2 +- crates/slumber_core/src/http/content_type.rs | 13 +- crates/slumber_core/src/http/models.rs | 11 + crates/slumber_core/src/template/render.rs | 2 +- crates/slumber_tui/Cargo.toml | 3 + .../src/view/common/text_window.rs | 18 +- .../src/view/component/queryable_body.rs | 23 +- .../src/view/component/recipe_pane.rs | 12 +- .../src/view/component/request_view.rs | 5 +- .../src/view/component/response_view.rs | 1 + crates/slumber_tui/src/view/theme.rs | 8 +- crates/slumber_tui/src/view/util.rs | 2 + crates/slumber_tui/src/view/util/highlight.rs | 457 ++++++++++++++++++ slumber.yml | 10 +- 17 files changed, 586 insertions(+), 21 deletions(-) create mode 100644 crates/slumber_tui/src/view/util/highlight.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8653279a..98608124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Remove `Config` line from `slumber show paths` output - Config file location can still be retrieved in the help menu of the TUI +### Added + +- Add syntax highlight to recipe, request, and response display [#264](https://github.com/LucasPickering/slumber/issues/264) + ### Changed - Upgrade to Rust 1.80 diff --git a/Cargo.lock b/Cargo.lock index ca8d584a..c9a5be7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2161,6 +2161,7 @@ dependencies = [ "itertools 0.13.0", "notify", "persisted", + "pretty_assertions", "ratatui", "reqwest", "rstest", @@ -2173,6 +2174,8 @@ dependencies = [ "strum", "tokio", "tracing", + "tree-sitter-highlight", + "tree-sitter-json", "uuid", ] @@ -2463,6 +2466,38 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tree-sitter" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7cc499ceadd4dcdf7ec6d4cbc34ece92c3fa07821e287aedecd4416c516dca" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaca0fe34fa96eec6aaa8e63308dbe1bafe65a6317487c287f93938959b21907" +dependencies = [ + "lazy_static", + "regex", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-json" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b737dcb73c35d74b7d64a5f3dde158113c86a012bf3cee2bfdf2150d23b05db" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree_magic_mini" version = "3.1.4" diff --git a/Cargo.toml b/Cargo.toml index b3d22295..077b415b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ derive_more = {version = "1.0.0-beta.6", default-features = false} futures = "0.3.28" indexmap = {version = "2.0.0", default-features = false} itertools = "0.13.0" +pretty_assertions = "1.4.0" reqwest = {version = "0.12.5", default-features = false} rstest = {version = "0.21.0", default-features = false} serde = {version = "1.0.204", default-features = false} diff --git a/crates/slumber_core/Cargo.toml b/crates/slumber_core/Cargo.toml index ef3a96bb..c05b9747 100644 --- a/crates/slumber_core/Cargo.toml +++ b/crates/slumber_core/Cargo.toml @@ -42,7 +42,7 @@ uuid = {workspace = true, features = ["serde", "v4"]} winnow = "0.6.16" [dev-dependencies] -pretty_assertions = "1.4.0" +pretty_assertions = {workspace = true} regex = {version = "1.10.5", default-features = false} rstest = {workspace = true} serde_test = {workspace = true} diff --git a/crates/slumber_core/src/http/content_type.rs b/crates/slumber_core/src/http/content_type.rs index 4a6870a1..26271e96 100644 --- a/crates/slumber_core/src/http/content_type.rs +++ b/crates/slumber_core/src/http/content_type.rs @@ -9,7 +9,7 @@ use crate::{http::ResponseRecord, util::Mapping}; use anyhow::{anyhow, Context}; use derive_more::{Deref, Display, From}; use mime::{Mime, APPLICATION, JSON}; -use reqwest::header::{self, HeaderValue}; +use reqwest::header::{self, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, ffi::OsStr, fmt::Debug, path::Path}; @@ -24,7 +24,7 @@ use std::{borrow::Cow, ffi::OsStr, fmt::Debug, path::Path}; /// /// For the serialization string, obviously use serde. For the others, use /// the corresponding methods/associated functions. -#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContentType { Json, @@ -64,10 +64,9 @@ impl ContentType { .ok_or_else(|| anyhow!("Unknown extension `{extension}`")) } - /// Parse the content type from a response's `Content-Type` header - pub fn from_response(response: &ResponseRecord) -> anyhow::Result { - let header_value = response - .headers + /// Parse the content type from the `Content-Type` header + pub fn from_headers(headers: &HeaderMap) -> anyhow::Result { + let header_value = headers .get(header::CONTENT_TYPE) .map(HeaderValue::as_bytes) .ok_or_else(|| anyhow!("Response has no content-type header"))?; @@ -105,7 +104,7 @@ impl ContentType { pub(super) fn parse_response( response: &ResponseRecord, ) -> anyhow::Result> { - let content_type = Self::from_response(response)?; + let content_type = Self::from_headers(&response.headers)?; content_type.parse_content(response.body.bytes()) } } diff --git a/crates/slumber_core/src/http/models.rs b/crates/slumber_core/src/http/models.rs index a103272b..2b0d656f 100644 --- a/crates/slumber_core/src/http/models.rs +++ b/crates/slumber_core/src/http/models.rs @@ -380,6 +380,17 @@ impl ResponseRecord { } } + /// Get the content type of the response body, according to the + /// `Content-Type` header + pub fn content_type(&self) -> Option { + // If we've parsed the body, we'll have the content type present. If + // not, check the header now + self.body + .parsed() + .map(|content| content.content_type()) + .or_else(|| ContentType::from_headers(&self.headers).ok()) + } + /// Get a suggested file name for the content of this response. First we'll /// check the Content-Disposition header. If it's missing or doesn't have a /// file name, we'll check the Content-Type to at least guess at an diff --git a/crates/slumber_core/src/template/render.rs b/crates/slumber_core/src/template/render.rs index 8534013c..7492215d 100644 --- a/crates/slumber_core/src/template/render.rs +++ b/crates/slumber_core/src/template/render.rs @@ -384,7 +384,7 @@ impl<'a> TemplateSource<'a> for ChainTemplateSource<'a> { self.get_response(context, recipe, *trigger).await?; // Guess content type based on HTTP header let content_type = - ContentType::from_response(&response).ok(); + ContentType::from_headers(&response.headers).ok(); let value = self.extract_response_value(response, section)?; (value, content_type) diff --git a/crates/slumber_tui/Cargo.toml b/crates/slumber_tui/Cargo.toml index 23b13f8a..5e40b814 100644 --- a/crates/slumber_tui/Cargo.toml +++ b/crates/slumber_tui/Cargo.toml @@ -31,9 +31,12 @@ slumber_core = {path = "../slumber_core", version = "1.7.0"} strum = {workspace = true} tokio = {workspace = true, features = ["macros", "signal"]} tracing = {workspace = true} +tree-sitter-highlight = "0.22.6" +tree-sitter-json = "0.21.0" uuid = {workspace = true} [dev-dependencies] +pretty_assertions = {workspace = true} rstest = {workspace = true} serde_test = {workspace = true} slumber_core = {path = "../slumber_core", features = ["test"]} diff --git a/crates/slumber_tui/src/view/common/text_window.rs b/crates/slumber_tui/src/view/common/text_window.rs index c3d01b9d..76400cbc 100644 --- a/crates/slumber_tui/src/view/common/text_window.rs +++ b/crates/slumber_tui/src/view/common/text_window.rs @@ -5,6 +5,7 @@ use crate::{ common::scrollbar::Scrollbar, draw::{Draw, DrawMetadata}, event::{Event, EventHandler, Update}, + util::highlight, }, }; use ratatui::{ @@ -14,11 +15,12 @@ use ratatui::{ widgets::{Paragraph, ScrollbarOrientation}, Frame, }; -use std::{cell::Cell, cmp, fmt::Debug}; +use slumber_core::http::content_type::ContentType; +use std::{cell::Cell, cmp}; /// A scrollable (but not editable) block of text. Internal state will be /// updated on each render, to adjust to the text's width/height. -#[derive(Debug, Default)] +#[derive(derive_more::Debug, Default)] pub struct TextWindow { offset_x: u16, offset_y: u16, @@ -31,6 +33,8 @@ pub struct TextWindow { pub struct TextWindowProps<'a> { /// Text to render pub text: Text<'a>, + /// Language of the content; pass to enable syntax highlighting + pub content_type: Option, /// Is there a search box below the content? This tells us if we need to /// offset the horizontal scroll box an extra row. pub has_search_box: bool, @@ -106,7 +110,15 @@ impl<'a> Draw> for TextWindow { metadata: DrawMetadata, ) { let styles = &TuiContext::get().styles; - let text = Paragraph::new(props.text); + + // Apply syntax highlighting + let text = if let Some(content_type) = props.content_type { + highlight::highlight(content_type, props.text) + } else { + props.text + }; + + let text = Paragraph::new(text); // Assume no line wrapping when calculating line count let text_height = text.line_count(u16::MAX) as u16; diff --git a/crates/slumber_tui/src/view/component/queryable_body.rs b/crates/slumber_tui/src/view/component/queryable_body.rs index 435117be..a96d1ac7 100644 --- a/crates/slumber_tui/src/view/component/queryable_body.rs +++ b/crates/slumber_tui/src/view/component/queryable_body.rs @@ -21,7 +21,7 @@ use ratatui::{ }; use serde_json_path::JsonPath; use slumber_core::{ - http::{query::Query, ResponseBody}, + http::{content_type::ContentType, query::Query, ResponseBody}, util::{MaybeStr, ResultTraced}, }; use std::{cell::Cell, ops::Deref}; @@ -49,6 +49,13 @@ pub struct QueryableBody { #[derive(Clone)] pub struct QueryableBodyProps<'a> { + /// Type of the body content; include for syntax highlighting + pub content_type: Option, + /// Body content. Theoretically this component isn't specific to responses, + /// but that's the only place where it's used currently so we specifically + /// accept a response body. By keeping it 90% agnostic (i.e. not accepting + /// a full response), it makes it easier to adapt in the future if we want + /// to make request bodies queryable as well. pub body: &'a ResponseBody, } @@ -163,6 +170,7 @@ impl<'a> Draw> for QueryableBody { frame, TextWindowProps { text: text.deref().generate(), + content_type: props.content_type, has_search_box: query_available, }, body_area, @@ -201,7 +209,8 @@ fn init_state(body: &ResponseBody, query: Option<&Query>) -> String { // Query and prettify text if possible. This involves a lot of cloning // because it makes stuff easier. If it becomes a bottleneck on large // responses it's fixable. - body.parsed() + let body = body + .parsed() .map(|parsed_body| { // Body is a known content type so we parsed it - apply a query if // necessary and prettify the output @@ -211,7 +220,8 @@ fn init_state(body: &ResponseBody, query: Option<&Query>) -> String { }) // Content couldn't be parsed, fall back to the raw text // If the text isn't UTF-8, we'll show a placeholder instead - .unwrap_or_else(|| format!("{:#}", MaybeStr(body.bytes()))) + .unwrap_or_else(|| format!("{:#}", MaybeStr(body.bytes()))); + body } #[cfg(test)] @@ -256,7 +266,10 @@ mod tests { let component = TestComponent::new( harness, QueryableBody::new(), - QueryableBodyProps { body: &body }, + QueryableBodyProps { + content_type: None, + body: &body, + }, ); // Assert state @@ -285,6 +298,7 @@ mod tests { harness, QueryableBody::new(), QueryableBodyProps { + content_type: None, body: &json_response.body, }, ); @@ -361,6 +375,7 @@ mod tests { harness, PersistedLazy::new(Key, QueryableBody::new()), QueryableBodyProps { + content_type: None, body: &json_response.body, }, ); diff --git a/crates/slumber_tui/src/view/component/recipe_pane.rs b/crates/slumber_tui/src/view/component/recipe_pane.rs index 3ee28a8f..5dcb5800 100644 --- a/crates/slumber_tui/src/view/component/recipe_pane.rs +++ b/crates/slumber_tui/src/view/component/recipe_pane.rs @@ -34,7 +34,7 @@ use slumber_core::{ Authentication, Folder, HasId, ProfileId, Recipe, RecipeBody, RecipeId, RecipeNode, }, - http::BuildOptions, + http::{content_type::ContentType, BuildOptions}, template::Template, util::doc_link, }; @@ -557,6 +557,8 @@ impl Draw for AuthenticationDisplay { #[derive(Debug)] enum RecipeBodyDisplay { Raw { + /// Needed for syntax highlighting + content_type: Option, preview: TemplatePreview, text_window: Component, }, @@ -572,6 +574,11 @@ impl RecipeBodyDisplay { ) -> Self { match body { RecipeBody::Raw(body) => Self::Raw { + // Hypothetically we could grab the content type from the + // Content-Type header above and plumb it down here, but more + // effort than it's worth IMO. This gives users a solid reason + // to use !json anyway + content_type: None, preview: TemplatePreview::new( body.clone(), selected_profile_id, @@ -595,6 +602,7 @@ impl RecipeBodyDisplay { .parse() .expect("Unexpected template parse failure"); Self::Raw { + content_type: Some(ContentType::Json), preview: TemplatePreview::new( template, selected_profile_id, @@ -647,12 +655,14 @@ impl Draw for RecipeBodyDisplay { fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) { match self { RecipeBodyDisplay::Raw { + content_type, preview, text_window, } => text_window.draw( frame, TextWindowProps { text: preview.generate(), + content_type: *content_type, has_search_box: false, }, metadata.area(), diff --git a/crates/slumber_tui/src/view/component/request_view.rs b/crates/slumber_tui/src/view/component/request_view.rs index 15e4d6bb..efec2048 100644 --- a/crates/slumber_tui/src/view/component/request_view.rs +++ b/crates/slumber_tui/src/view/component/request_view.rs @@ -16,7 +16,7 @@ use crate::{ use derive_more::Display; use ratatui::{layout::Layout, prelude::Constraint, Frame}; use slumber_core::{ - http::{RequestId, RequestRecord}, + http::{content_type::ContentType, RequestId, RequestRecord}, util::MaybeStr, }; use std::sync::Arc; @@ -132,10 +132,13 @@ impl Draw for RequestView { headers_area, ); if let Some(body) = &state.body { + let content_type = + ContentType::from_headers(&props.request.headers).ok(); self.body_text_window.draw( frame, TextWindowProps { text: body.generate(), + content_type, has_search_box: false, }, body_area, diff --git a/crates/slumber_tui/src/view/component/response_view.rs b/crates/slumber_tui/src/view/component/response_view.rs index 34dcfa4c..3d718561 100644 --- a/crates/slumber_tui/src/view/component/response_view.rs +++ b/crates/slumber_tui/src/view/component/response_view.rs @@ -154,6 +154,7 @@ impl<'a> Draw> for ResponseBodyView { state.body.draw( frame, QueryableBodyProps { + content_type: response.content_type(), body: &response.body, }, metadata.area(), diff --git a/crates/slumber_tui/src/view/theme.rs b/crates/slumber_tui/src/view/theme.rs index 5ef11b0f..ab66085c 100644 --- a/crates/slumber_tui/src/view/theme.rs +++ b/crates/slumber_tui/src/view/theme.rs @@ -196,8 +196,12 @@ impl Styles { title: Style::default().add_modifier(Modifier::BOLD), }, template_preview: TemplatePreviewStyles { - text: Style::default().fg(theme.secondary_color), - error: Style::default().bg(theme.error_color), + text: Style::default() + .fg(theme.secondary_color) + .add_modifier(Modifier::UNDERLINED), + error: Style::default() + .fg(Color::default()) // Override syntax highlighting + .bg(theme.error_color), }, text: TextStyle { highlight: Style::default() diff --git a/crates/slumber_tui/src/view/util.rs b/crates/slumber_tui/src/view/util.rs index fd364d8a..a702f322 100644 --- a/crates/slumber_tui/src/view/util.rs +++ b/crates/slumber_tui/src/view/util.rs @@ -1,5 +1,7 @@ //! Helper structs and functions for building components +pub mod highlight; + use ratatui::layout::{Constraint, Direction, Layout, Rect}; use slumber_core::template::{Prompt, PromptChannel, Prompter}; diff --git a/crates/slumber_tui/src/view/util/highlight.rs b/crates/slumber_tui/src/view/util/highlight.rs new file mode 100644 index 00000000..3eff1f34 --- /dev/null +++ b/crates/slumber_tui/src/view/util/highlight.rs @@ -0,0 +1,457 @@ +//! Utilities for applying syntax highlighting to text. +//! +//! Warning: this thing is kinda fucked. + +use anyhow::Context; +use itertools::Itertools; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span, Text}, +}; +use slumber_core::{http::content_type::ContentType, util::ResultTraced}; +use std::{ + borrow::Cow, + cell::RefCell, + collections::{HashMap, VecDeque}, +}; +use strum::{EnumIter, IntoEnumIterator}; +use tree_sitter_highlight::{ + Highlight, HighlightConfiguration, HighlightEvent, Highlighter, +}; + +thread_local! { + /// Cache the highlighter and its configurations, because we only need one + /// per thread. The view is single threaded, which means we only create one + static HIGHLIGHTER: RefCell<( + Highlighter, + HashMap, + )> = RefCell::default(); +} + +/// Apply syntax highlighting to some text. Syntax language will be determined +/// from the content type. +pub fn highlight(content_type: ContentType, mut text: Text<'_>) -> Text<'_> { + HIGHLIGHTER.with_borrow_mut(|(highlighter, configs)| { + let config = configs + .entry(content_type) + .or_insert_with(|| get_config(content_type)); + + // Each line in the input correponds to one line in the output, so we + // can mutate each line inline + for line in &mut text.lines { + // Join the line into a single string so we can pass it to the + // highlighter. Unfortunately it can't handle subline parsing, it + // needs at least a line at a time + let joined = join_line(line); + let Ok(events) = highlighter + .highlight(config, joined.as_bytes(), None, |_| None) + .context("Syntax highlighting error") + .traced() + else { + continue; // Leave the line untouched + }; + + let mut builder = LineBuilder::new(line); + for event in events { + match event.context("Syntax highlighting error").traced() { + Ok(HighlightEvent::Source { start, end }) => { + builder.push_span(&joined, start, end); + } + Ok(HighlightEvent::HighlightStart(index)) => { + let name = HighlightName::from_index(index); + builder.set_style(name.style()); + } + Ok(HighlightEvent::HighlightEnd) => { + builder.reset_style(); + } + // Not sure what would cause an error here, it doesn't seem + // like invalid syntax does it + Err(_) => {} + } + } + + *line = builder.build(); + } + + text + }) +} + +/// Map [ContentType] to a syntax highlighting language +fn get_config(content_type: ContentType) -> HighlightConfiguration { + let mut config = match content_type { + ContentType::Json => HighlightConfiguration::new( + tree_sitter_json::language(), + "json", + tree_sitter_json::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("Error initializing JSON syntax highlighter"), + }; + config.configure( + HighlightName::iter() + .map(HighlightName::to_str) + .collect_vec() + .as_slice(), + ); + config +} + +/// All highlight names that we support +/// +/// https://tree-sitter.github.io/tree-sitter/syntax-highlighting#highlights +/// +/// This enum should be the union of all highlight names in all supported langs: +/// - https://github.com/tree-sitter/tree-sitter-json/blob/94f5c527b2965465956c2000ed6134dd24daf2a7/queries/highlights.scm +#[derive(Copy, Clone, Debug, EnumIter)] +enum HighlightName { + Comment, + ConstantBuiltin, + Escape, + Number, + String, + StringSpecial, +} + +impl HighlightName { + /// Map to a string name, to pass to tree-sitter + fn to_str(self) -> &'static str { + match self { + Self::Comment => "comment", + Self::ConstantBuiltin => "constant.builtin", + Self::Escape => "escape", + Self::Number => "number", + Self::String => "string", + // This doesn't seem to work?? + Self::StringSpecial => "string.special", + } + } + + /// Tree-sitter passes highlights back as the index. This relies on a + /// consistent iteration order of + fn from_index(highlight: Highlight) -> Self { + let index = highlight.0; + Self::iter() + .nth(index) + .unwrap_or_else(|| panic!("Highlight index out of bounds: {index}")) + } + + fn style(self) -> Style { + // We only style by foreground for syntax + let fg = match self { + Self::Comment => Color::Gray, + Self::ConstantBuiltin => Color::Blue, + Self::Escape => Color::Green, + Self::Number => Color::Cyan, + Self::String => Color::LightGreen, + Self::StringSpecial => Color::Green, + }; + Style::default().fg(fg) + } +} + +/// Join all text in a line into a single string. For single-span lines (the +/// most common scneario by far), we'll just return the one span without a +/// clone. +fn join_line<'a>(line: &Line<'a>) -> Cow<'a, str> { + if line.spans.is_empty() { + Default::default() + } else if line.spans.len() == 1 { + // This is the hot path, most lines will just be one unstyled span. In + // most scenarios we'll be getting borrowed content so the clone's cheap + line.spans[0].content.clone() + } else { + // We have multiple spans, join them into a new string + let mut text = String::with_capacity( + line.spans.iter().map(|span| span.content.len()).sum(), + ); + for span in &line.spans { + text.push_str(&span.content); + } + text.into() + } +} + +/// Utility for merging styles on text. Use [Self::new] to initialize this +/// *before* highlighting, and it will remember which chunks of text had +/// preexisting styles. Use the setters to update state while processing +/// highlight events. These will be reapplied during highlighting, as the new +/// line is built up. After highlighting, call [Self::build] to get the new +/// line. The old styles will take precedence over the syntax highlighting. +/// +/// This whole thing is required to retain template preview styling on top of +/// syntax highlighting. +struct LineBuilder<'a> { + /// A set of **disjoint** style patches that we'll apply to the new line as + /// it's being built. We need a deque because we'll pop off the front as + /// we go + patches: VecDeque, + /// New line being built + line: Line<'a>, + /// Style to be used for the *next* added span. This is updated + /// imperatively as we loop over highlighter events. + current_style: Style, +} + +impl<'a> LineBuilder<'a> { + /// Collect styles from a line to start a new builder + fn new(line: &Line<'a>) -> Self { + let mut patches = VecDeque::new(); + let mut len = 0; + for span in &line.spans { + if let Some(patch) = StylePatch::from_span(len, span) { + patches.push_back(patch); + } + len += span.content.len(); + } + + Self { + patches, + line: Line::default(), + current_style: Style::default(), + } + } + + /// Add a section of text to the new line. This will check if any cached + /// styles apply to this section, and if so break it into multiple spans as + /// needed to keep the old styling. + #[allow(clippy::ptr_arg)] + fn push_span(&mut self, text: &Cow<'a, str>, mut start: usize, end: usize) { + // Keep a reference if we can. If the text is owned, we have to clone + // because the owned value is going to get dropped after the build + let mut content: Cow<'a, str> = match text { + Cow::Borrowed(s) => s[start..end].into(), + Cow::Owned(s) => s[start..end].to_owned().into(), + }; + let style = self.current_style; + + while let Some(patch) = Self::next_patch(&mut self.patches, end) { + // The first part of this chunk is not covered by the patch + let (before, rest) = split_cow(content, patch.start - start); + let (patched, after) = split_cow(rest, patch.len); + let consumed = before.len() + patched.len(); + + if !before.is_empty() { + self.line.spans.push(Span { + content: before, + style, + }); + } + debug_assert!(!patched.is_empty(), "Patch should not be empty"); + self.line.spans.push(Span { + content: patched, + style: patch.style, + }); + // Everything left over is for the next iteration + content = after; + start += consumed; + } + + // Pull in whatever's left over. This is the hot path, because in most + // cases we won't have any patches to apply + if !content.is_empty() { + self.line.spans.push(Span { content, style }); + } + } + + /// Get the next patch in the sequence that applies before the given index. + /// If the patch spans both sides of the index, split it and leave the + /// second half in the queue + fn next_patch( + patches: &mut VecDeque, + before: usize, + ) -> Option { + match patches.front() { + Some(patch) if patch.start < before => {} + _ => return None, + } + // Don't pop until we know we're going to use it + let patch = patches.pop_front().unwrap(); + if before < patch.end() { + let (left, right) = patch.split(before); + patches.push_front(right); + Some(left) + } else { + Some(patch) + } + } + + fn set_style(&mut self, style: Style) { + self.current_style = style; + } + + fn reset_style(&mut self) { + self.current_style = Style::default(); + } + + /// Construct the line by applying pending style patches + fn build(self) -> Line<'a> { + debug_assert!( + self.patches.is_empty(), + "Patches remaining in queue: {:?}", + &self.patches + ); + self.line + } +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +struct StylePatch { + start: usize, + len: usize, + style: Style, +} + +impl StylePatch { + /// Create a new style patch for the given span, starting at the given + /// index. If the span has default styling, return `None`. + fn from_span(start: usize, span: &Span) -> Option { + if span.style != Style::default() { + Some(Self { + start, + len: span.content.len(), + style: span.style, + }) + } else { + None + } + } + + fn end(&self) -> usize { + self.start + self.len + } + + /// Split this patch into two sections at a certain index + fn split(self, at: usize) -> (Self, Self) { + debug_assert!( + self.start <= at && at < self.end(), + "Split index {at} is not in [{}, {})", + self.start, + self.end() + ); + let first_len = at - self.start; + ( + Self { + start: self.start, + len: first_len, + style: self.style, + }, + Self { + start: at, + len: self.len - first_len, + style: self.style, + }, + ) + } +} + +/// Split a cow into two substrings. If we have a borrowed string, return +/// subslices. If we have an owned string, we have to split into two owned +/// strings to prevent a self-reference. +fn split_cow(s: Cow<'_, str>, at: usize) -> (Cow<'_, str>, Cow<'_, str>) { + match s { + Cow::Borrowed(s) => { + let (first, second) = s.split_at(at); + (first.into(), second.into()) + } + Cow::Owned(mut first) => { + let second = first.split_off(at); + (first.into(), second.into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// Test that JSON is highlighted, by existing styling is retained + #[test] + fn test_highlight() { + fn fg(color: Color) -> Style { + Style::default().fg(color) + } + + let text = vec![ + Line::from("{"), + vec![ + " \"string\": \"".into(), + Span::styled("turkey", fg(Color::Blue)), + "🦃".into(), // Throw some multi-byte chars in for fun + Span::styled("day", fg(Color::Red)), + "🦃\",".into(), + ] + .into(), + " \"number\": 3,".into(), + // This whole thing should retain its style + Span::styled(" \"bool\": false", fg(Color::Red)).into(), + "}".into(), + ] + .into(); + let highlighted = highlight(ContentType::Json, text); + let expected = vec![ + Line::from("{"), + vec![ + " ".into(), + Span::styled("\"string\"", fg(Color::LightGreen)), + ": ".into(), + Span::styled("\"", fg(Color::LightGreen)), + Span::styled("turkey", fg(Color::Blue)), + Span::styled("🦃", fg(Color::LightGreen)), + Span::styled("day", fg(Color::Red)), + Span::styled("🦃\"", fg(Color::LightGreen)), + ",".into(), + ] + .into(), + vec![ + " ".into(), + Span::styled("\"number\"", fg(Color::LightGreen)), + ": ".into(), + Span::styled("3", fg(Color::Cyan)), + ",".into(), + ] + .into(), + // This whole line kept its styling, but it's broken up into spans + // now for "technical" reasons + vec![ + Span::styled(" ", fg(Color::Red)), + Span::styled("\"bool\"", fg(Color::Red)), + Span::styled(": ", fg(Color::Red)), + Span::styled("false", fg(Color::Red)), + ] + .into(), + "}".into(), + ] + .into(); + assert_eq!(highlighted, expected); + } + + /// Test [StylePatch::split] + #[test] + fn test_patch_split() { + let style = Style::default().fg(Color::Red); + assert_eq!( + StylePatch { + start: 10, + len: 4, + style, + } + .split(13), + ( + StylePatch { + start: 10, + len: 3, + style, + }, + StylePatch { + start: 13, + len: 1, + style, + } + ) + ); + } +} diff --git a/slumber.yml b/slumber.yml index 1d9f0616..e4da273c 100644 --- a/slumber.yml +++ b/slumber.yml @@ -38,6 +38,7 @@ chains: authentication: !bearer "{{chains.auth_token}}" headers: Accept: application/json + Content-Type: application/json requests: login: !request @@ -75,7 +76,14 @@ requests: name: Modify User method: PUT url: "{{host}}/anything/{{user_guid}}" - body: !json { "username": "new username" } + body: + !json { + "new_username": "user formerly known as {{chains.username}}", + "number": 3, + "bool": true, + "null": null, + "array": [1, 2, false, 3.3, "www.www"], + } get_image: !request headers: