Skip to content

Commit

Permalink
Add syntax highlight via tree-sitter-highlight
Browse files Browse the repository at this point in the history
Closes #264
  • Loading branch information
LucasPickering committed Jul 27, 2024
1 parent 4bff9fa commit fc10042
Show file tree
Hide file tree
Showing 17 changed files with 620 additions and 21 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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}
Expand Down
2 changes: 1 addition & 1 deletion crates/slumber_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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}
Expand Down
13 changes: 6 additions & 7 deletions crates/slumber_core/src/http/content_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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,
Expand Down Expand Up @@ -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<Self> {
let header_value = response
.headers
/// Parse the content type from the `Content-Type` header
pub fn from_headers(headers: &HeaderMap) -> anyhow::Result<Self> {
let header_value = headers
.get(header::CONTENT_TYPE)
.map(HeaderValue::as_bytes)
.ok_or_else(|| anyhow!("Response has no content-type header"))?;
Expand Down Expand Up @@ -105,7 +104,7 @@ impl ContentType {
pub(super) fn parse_response(
response: &ResponseRecord,
) -> anyhow::Result<Box<dyn ResponseContent>> {
let content_type = Self::from_response(response)?;
let content_type = Self::from_headers(&response.headers)?;
content_type.parse_content(response.body.bytes())
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/slumber_core/src/http/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,17 @@ impl ResponseRecord {
}
}

/// Get the content type of the response body, according to the
/// `Content-Type` header
pub fn content_type(&self) -> Option<ContentType> {
// 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
Expand Down
2 changes: 1 addition & 1 deletion crates/slumber_core/src/template/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions crates/slumber_tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,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"]}
18 changes: 15 additions & 3 deletions crates/slumber_tui/src/view/common/text_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::{
common::scrollbar::Scrollbar,
draw::{Draw, DrawMetadata},
event::{Event, EventHandler, Update},
util::highlight,
},
};
use ratatui::{
Expand All @@ -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,
Expand All @@ -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<ContentType>,
/// 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,
Expand Down Expand Up @@ -106,7 +110,15 @@ impl<'a> Draw<TextWindowProps<'a>> 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;

Expand Down
23 changes: 19 additions & 4 deletions crates/slumber_tui/src/view/component/queryable_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<ContentType>,
/// 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,
}

Expand Down Expand Up @@ -163,6 +170,7 @@ impl<'a> Draw<QueryableBodyProps<'a>> for QueryableBody {
frame,
TextWindowProps {
text: text.deref().generate(),
content_type: props.content_type,
has_search_box: query_available,
},
body_area,
Expand Down Expand Up @@ -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
Expand All @@ -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)]
Expand Down Expand Up @@ -256,7 +266,10 @@ mod tests {
let component = TestComponent::new(
harness,
QueryableBody::new(),
QueryableBodyProps { body: &body },
QueryableBodyProps {
content_type: None,
body: &body,
},
);

// Assert state
Expand Down Expand Up @@ -285,6 +298,7 @@ mod tests {
harness,
QueryableBody::new(),
QueryableBodyProps {
content_type: None,
body: &json_response.body,
},
);
Expand Down Expand Up @@ -361,6 +375,7 @@ mod tests {
harness,
PersistedLazy::new(Key, QueryableBody::new()),
QueryableBodyProps {
content_type: None,
body: &json_response.body,
},
);
Expand Down
12 changes: 11 additions & 1 deletion crates/slumber_tui/src/view/component/recipe_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -557,6 +557,8 @@ impl Draw for AuthenticationDisplay {
#[derive(Debug)]
enum RecipeBodyDisplay {
Raw {
/// Needed for syntax highlighting
content_type: Option<ContentType>,
preview: TemplatePreview,
text_window: Component<TextWindow>,
},
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 4 additions & 1 deletion crates/slumber_tui/src/view/component/request_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,10 +132,13 @@ impl Draw<RequestViewProps> 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,
Expand Down
1 change: 1 addition & 0 deletions crates/slumber_tui/src/view/component/response_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ impl<'a> Draw<ResponseBodyViewProps<'a>> for ResponseBodyView {
state.body.draw(
frame,
QueryableBodyProps {
content_type: response.content_type(),
body: &response.body,
},
metadata.area(),
Expand Down
8 changes: 6 additions & 2 deletions crates/slumber_tui/src/view/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions crates/slumber_tui/src/view/util.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down
Loading

0 comments on commit fc10042

Please sign in to comment.