From a35a13070e635eaf23352c54d07c55afbfed44cb Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Oct 2018 20:14:28 +0100 Subject: [PATCH 01/12] Refactor terminal representation again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Terminal trait in favour of a TerminalCapabilities struct, and explicitly pattern match over supported capabilities while rendering. This removes boxing and dynamic dispatch, and makes different features and internal behaviour like HTTP access more explicit. We now also explicitly check what and how certain features are supported while rendering; this gets rids of the NotSupportedError thing and the kludge to handle it in a special way which I never quite liked. Also simply how we check for access to resources: Remove the resource enum and instead use Url directly. Via the scheme an URL already tells us whether it’s local or not, so we just rely on the scheme and file path conversion now. This makes resolving references in markdown files and checking access much simpler. --- Cargo.toml | 17 +- src/error.rs | 50 ---- src/lib.rs | 209 ++++++++------ src/magic.rs | 2 +- src/main.rs | 27 +- src/resources.rs | 518 ++++++++++++++++------------------- src/terminal/ansi.rs | 94 ++----- src/terminal/dumb.rs | 76 ----- src/terminal/highlighting.rs | 7 +- src/terminal/iterm2.rs | 193 ++++++------- src/terminal/mod.rs | 172 ++++++++---- src/terminal/osc.rs | 39 +++ src/terminal/terminology.rs | 112 +++----- src/terminal/vte50.rs | 89 ------ src/terminal/write.rs | 75 ----- tests/formatting.rs | 4 +- 16 files changed, 690 insertions(+), 994 deletions(-) delete mode 100644 src/error.rs delete mode 100644 src/terminal/dumb.rs create mode 100644 src/terminal/osc.rs delete mode 100644 src/terminal/vte50.rs delete mode 100644 src/terminal/write.rs diff --git a/Cargo.toml b/Cargo.toml index 78a1a209..aa813a1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,16 +15,15 @@ authors = ["Sebastian Wiesner "] travis-ci = { repository = "lunaryorn/mdcat" } [features] -default = ["iterm2", "terminology", "remote_resources"] +default = ["vte50", "iterm2", "terminology"] -# Enable special support for iTerm2. -iterm2 = ["mime", "base64"] -# Support for remote resources, eg, images. Note some terminals, eg, -# Terminology, support remote resources out of the box even if this feature is -# not active. -remote_resources = ["reqwest"] -# Enable special support for Terminology. -terminology = ["immeta"] +# Special terminal features +osc8_links = [] + +# Terminal emulators +iterm2 = ["osc8_links", "mime", "base64"] +terminology = ["osc8_links", "immeta"] +vte50 = ["osc8_links"] [dependencies] failure = "^0.1" diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 061ae2c4..00000000 --- a/src/error.rs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Terminal errors. - -use failure::Error; - -/// The terminal does not support something. -#[derive(PartialEq, Debug, Fail)] -#[fail(display = "This terminal does not support {}.", what)] -pub struct NotSupportedError { - /// The operation which the terminal did not support. - pub what: &'static str, -} - -/// Ignore a `NotSupportedError`. -pub trait IgnoreNotSupported { - /// The type after ignoring `NotSupportedError`. - type R; - - /// Elide a `NotSupportedError` from this value. - fn ignore_not_supported(self) -> Self::R; -} - -impl IgnoreNotSupported for Error { - type R = Result<(), Error>; - - fn ignore_not_supported(self) -> Self::R { - self.downcast::().map(|_| ()) - } -} - -impl IgnoreNotSupported for Result<(), Error> { - type R = Result<(), Error>; - - fn ignore_not_supported(self) -> Self::R { - self.or_else(|err| err.ignore_not_supported()) - } -} diff --git a/src/lib.rs b/src/lib.rs index e95a5331..9008f01f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,22 +21,21 @@ //! Write markdown to TTYs. // Used by remote_resources to actually fetch remote resources over HTTP -#[cfg(feature = "remote_resources")] -extern crate reqwest; +// #[cfg(feature = "remote_resources")] +// extern crate reqwest; // Used by iTerm support on macos -#[cfg(feature = "iterm2")] -extern crate base64; -#[cfg(feature = "iterm2")] -extern crate mime; +// #[cfg(feature = "iterm2")] +// extern crate base64; +// #[cfg(feature = "iterm2")] +// extern crate mime; // Used by Terminology support #[cfg(feature = "terminology")] extern crate immeta; -#[macro_use] -extern crate failure; extern crate ansi_term; +extern crate failure; extern crate pulldown_cmark; extern crate syntect; extern crate term_size; @@ -54,26 +53,14 @@ use std::path::Path; use syntect::easy::HighlightLines; use syntect::highlighting::{Theme, ThemeSet}; use syntect::parsing::SyntaxSet; +use url::Url; -// These modules support iterm2; we do not need them if iterm2 is off. -#[cfg(feature = "iterm2")] -mod magic; -#[cfg(feature = "iterm2")] -mod process; -#[cfg(feature = "iterm2")] -mod svg; - -mod error; mod resources; mod terminal; -use resources::Resource; -use terminal::*; - // Expose some select things for use in main pub use resources::ResourceAccess; -pub use terminal::Size as TerminalSize; -pub use terminal::{detect_terminal, write_styled, AnsiTerminal, DumbTerminal, Terminal}; +pub use terminal::*; /// Dump markdown events to a writer. pub fn dump_events<'a, W, I>(writer: &mut W, events: I) -> Result<(), Error> @@ -95,7 +82,8 @@ where /// `push_tty` tries to limit output to the given number of TTY `columns` but /// does not guarantee that output stays within the column limit. pub fn push_tty<'a, W, I>( - terminal: &'a mut dyn Terminal, + writer: &'a mut W, + capabilities: TerminalCapabilities, size: TerminalSize, events: I, base_dir: &'a Path, @@ -107,7 +95,15 @@ where W: Write, { let theme = &ThemeSet::load_defaults().themes["Solarized (dark)"]; - let mut context = Context::new(terminal, size, base_dir, resource_access, syntax_set, theme); + let mut context = Context::new( + writer, + capabilities, + size, + base_dir, + resource_access, + syntax_set, + theme, + ); for event in events { write_event(&mut context, event)?; } @@ -155,17 +151,25 @@ struct InputContext<'a> { impl<'a> InputContext<'a> { /// Resolve a reference in the input. - fn resolve_reference(&self, reference: &'a str) -> Resource { - Resource::from_reference(self.base_dir, reference) + /// + /// If `reference` parses as URL return the parsed URL. Otherwise assume + /// `reference` is a file path, resolve it against `base_dir` and turn it + /// into a file:// URL. If this also fails return `None`. + fn resolve_reference(&self, reference: &'a str) -> Option { + Url::parse(reference) + .or_else(|_| Url::from_file_path(self.base_dir.join(reference))) + .ok() } } /// Context for TTY output. struct OutputContext<'a, W: Write + 'a> { /// The terminal dimensions to limit output to. - size: Size, - /// The target terminal. - terminal: &'a mut dyn Terminal, + size: TerminalSize, + /// A writer to the terminal. + writer: &'a mut W, + /// The capabilities of the terminal. + capabilities: TerminalCapabilities, } #[derive(Debug)] @@ -257,8 +261,9 @@ struct Context<'a, W: Write + 'a> { impl<'a, W: Write> Context<'a, W> { fn new( - terminal: &'a mut dyn Terminal, - size: Size, + writer: &'a mut W, + capabilities: TerminalCapabilities, + size: TerminalSize, base_dir: &'a Path, resource_access: ResourceAccess, syntax_set: SyntaxSet, @@ -269,7 +274,11 @@ impl<'a, W: Write> Context<'a, W> { base_dir, resource_access, }, - output: OutputContext { size, terminal }, + output: OutputContext { + size, + writer, + capabilities, + }, style: StyleContext { current: Style::new(), previous: Vec::new(), @@ -328,7 +337,7 @@ impl<'a, W: Write> Context<'a, W> { /// /// Restart all current styles after the newline. fn newline(&mut self) -> Result<(), Error> { - writeln!(self.output.terminal.write())?; + writeln!(self.output.writer)?; Ok(()) } @@ -344,7 +353,7 @@ impl<'a, W: Write> Context<'a, W> { /// Indent according to the current indentation level. fn indent(&mut self) -> Result<(), Error> { write!( - self.output.terminal.write(), + self.output.writer, "{}", " ".repeat(self.block.indent_level) ).map_err(Into::into) @@ -369,7 +378,12 @@ impl<'a, W: Write> Context<'a, W> { /// Write `text` with the given `style`. fn write_styled>(&mut self, style: &Style, text: S) -> Result<(), Error> { - write_styled(&mut *self.output.terminal, style, text)?; + match self.output.capabilities.style { + StyleCapability::None => writeln!(self.output.writer, "{}", text.as_ref())?, + StyleCapability::Ansi(ref ansi) => { + ansi.write_styled(self.output.writer, style, text)? + } + } Ok(()) } @@ -437,16 +451,18 @@ impl<'a, W: Write> Context<'a, W> { /// If the code context has a highlighter, use it to highlight `text` and /// write it. Otherwise write `text` without highlighting. fn write_highlighted(&mut self, text: Cow<'a, str>) -> Result<(), Error> { - match self.code.current_highlighter { - Some(ref mut highlighter) => { + let mut wrote_highlighted: bool = false; + if let Some(ref mut highlighter) = self.code.current_highlighter { + if let StyleCapability::Ansi(ref ansi) = self.output.capabilities.style { let regions = highlighter.highlight(&text, &self.code.syntax_set); - write_as_ansi(&mut *self.output.terminal, ®ions)?; - } - None => { - self.write_styled_current(&text)?; - self.links.last_text = Some(text); + highlighting::write_as_ansi(self.output.writer, ansi, ®ions)?; + wrote_highlighted = true; } } + if !wrote_highlighted { + self.write_styled_current(&text)?; + self.links.last_text = Some(text); + } Ok(()) } } @@ -497,7 +513,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err // them close to the text where they appeared in ctx.write_pending_links()?; ctx.start_inline_text()?; - ctx.output.terminal.set_mark().ignore_not_supported()?; + // ctx.output.terminal.set_mark().ignore_not_supported()?; ctx.set_style(Style::new().fg(Colour::Blue).bold()); ctx.write_styled_current("\u{2504}".repeat(level as usize))? } @@ -511,27 +527,25 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err CodeBlock(name) => { ctx.start_inline_text()?; ctx.write_border()?; - if ctx.output.terminal.supports_styles() { - // Try to get a highlighter for the current code. - ctx.code.current_highlighter = if name.is_empty() { - None - } else { - ctx.code - .syntax_set - .find_syntax_by_token(&name) - .map(|syntax| HighlightLines::new(syntax, ctx.code.theme)) - }; - if ctx.code.current_highlighter.is_none() { - // If we found no highlighter (code block had no language or - // a language synctex doesn't support) we set a style to - // highlight the code as generic fixed block. - // - // If we have a highlighter we set no style at all because - // we pass the entire block contents through the highlighter - // and directly write the result as ANSI. - let style = ctx.style.current.fg(Colour::Yellow); - ctx.set_style(style); - } + // Try to get a highlighter for the current code. + ctx.code.current_highlighter = if name.is_empty() { + None + } else { + ctx.code + .syntax_set + .find_syntax_by_token(&name) + .map(|syntax| HighlightLines::new(syntax, ctx.code.theme)) + }; + if ctx.code.current_highlighter.is_none() { + // If we found no highlighter (code block had no language or + // a language synctex doesn't support) we set a style to + // highlight the code as generic fixed block. + // + // If we have a highlighter we set no style at all because + // we pass the entire block contents through the highlighter + // and directly write the result as ANSI. + let style = ctx.style.current.fg(Colour::Yellow); + ctx.set_style(style); } } List(kind) => { @@ -546,12 +560,12 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err ctx.block.level = BlockLevel::Inline; match ctx.list_item_kind.pop() { Some(ListItemKind::Unordered) => { - write!(ctx.output.terminal.write(), "\u{2022} ")?; + write!(ctx.output.writer, "\u{2022} ")?; ctx.block.indent_level += 2; ctx.list_item_kind.push(ListItemKind::Unordered); } Some(ListItemKind::Ordered(number)) => { - write!(ctx.output.terminal.write(), "{:>2}. ", number)?; + write!(ctx.output.writer, "{:>2}. ", number)?; ctx.block.indent_level += 4; ctx.list_item_kind.push(ListItemKind::Ordered(number + 1)); } @@ -570,28 +584,45 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err ctx.set_style(style) } Link(destination, _) => { - // Try to create an inline link, provided that the format supports - // those and we can parse the destination as valid URL. If we can't - // or if the format doesn't support inline links, don't do anything - // here; we will write a reference link when closing the link tag. - let url = ctx.input.resolve_reference(&destination).into_url(); - if ctx.output.terminal.set_link(url.as_str()).is_ok() { - ctx.links.inside_inline_link = true; + // Do nothing if the terminal doesn’t support inline links of if + // `destination` is no valid URL: We will write a reference link + // when closing the link tag. + match ctx.output.capabilities.links { + #[cfg(feature = "osc8_links")] + LinkCapability::OSC8(ref osc8) => { + if let Some(url) = ctx.input.resolve_reference(&destination) { + osc8.set_link(ctx.output.writer, url.as_str())?; + ctx.links.inside_inline_link = true; + } + } + LinkCapability::None => { + // Just mark destination as used + drop(destination); + } } } - Image(link, _title) => { - let resource = ctx.input.resolve_reference(&link); - if ctx - .output - .terminal - .write_inline_image(ctx.output.size, &resource, ctx.input.resource_access) - .is_ok() - { - // If we could write an inline image, disable text output to - // suppress the image title. - ctx.image.inline_image = true; + Image(link, _title) => match ctx.output.capabilities.image { + #[cfg(feature = "terminology")] + ImageCapability::Terminology(ref mut terminology) => { + let access = ctx.input.resource_access; + if let Some(url) = ctx + .input + .resolve_reference(&link) + .filter(|url| access.permits(url)) + { + terminology.write_inline_image( + &mut ctx.output.writer, + ctx.output.size, + &url, + )?; + ctx.image.inline_image = true; + } } - } + ImageCapability::None => { + // Just to mark "link" as used + drop(link); + } + }, }; Ok(()) } @@ -649,7 +680,13 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E } Strong | Code => ctx.drop_style(), Link(destination, title) => if ctx.links.inside_inline_link { - ctx.output.terminal.set_link("")?; + match ctx.output.capabilities.links { + #[cfg(feature = "osc8_links")] + LinkCapability::OSC8(ref osc8) => { + osc8.clear_link(ctx.output.writer)?; + } + _ => {} + } ctx.links.inside_inline_link = false; } else { // When we did not write an inline link, create a normal reference diff --git a/src/magic.rs b/src/magic.rs index 10249aea..0279c835 100644 --- a/src/magic.rs +++ b/src/magic.rs @@ -15,7 +15,7 @@ //! Detect mime type with `file`. use failure::Error; -use mime::Mime; +// use mime::Mime; use std::io::prelude::*; use std::process::*; use std::str; diff --git a/src/main.rs b/src/main.rs index fdef4d13..3fb386c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,11 @@ use pulldown_cmark::Parser; use std::error::Error; use std::fs::File; use std::io::prelude::*; -use std::io::{stdin, stdout, Stdout}; +use std::io::{stdin, stdout}; use std::path::PathBuf; use syntect::parsing::SyntaxSet; -use mdcat::{detect_terminal, AnsiTerminal, DumbTerminal, ResourceAccess, Terminal, TerminalSize}; +use mdcat::{ResourceAccess, TerminalCapabilities, TerminalSize}; /// Read input for `filename`. /// @@ -59,9 +59,9 @@ fn read_input>(filename: T) -> std::io::Result<(PathBuf, String)> } } -fn process_arguments(size: TerminalSize, args: &mut Arguments) -> Result<(), Box> { +fn process_arguments(size: TerminalSize, args: Arguments) -> Result<(), Box> { if args.detect_only { - println!("Terminal: {}", args.terminal.name()); + println!("Terminal: {}", args.terminal_capabilities.name); Ok(()) } else { let (base_dir, input) = read_input(&args.filename)?; @@ -73,7 +73,8 @@ fn process_arguments(size: TerminalSize, args: &mut Arguments) -> Result<(), Box } else { let syntax_set = SyntaxSet::load_defaults_newlines(); mdcat::push_tty( - &mut (*args.terminal), + &mut stdout(), + args.terminal_capabilities, TerminalSize { width: args.columns, ..size @@ -91,7 +92,7 @@ fn process_arguments(size: TerminalSize, args: &mut Arguments) -> Result<(), Box /// Represent command line arguments. struct Arguments { filename: String, - terminal: Box>, + terminal_capabilities: TerminalCapabilities, resource_access: ResourceAccess, columns: usize, dump_events: bool, @@ -101,13 +102,13 @@ struct Arguments { impl Arguments { /// Create command line arguments from matches. fn from_matches(matches: &clap::ArgMatches) -> clap::Result { - let terminal = if matches.is_present("no_colour") { + let terminal_capabilities = if matches.is_present("no_colour") { // If the user disabled colours assume a dumb terminal - Box::new(DumbTerminal::new(stdout())) + TerminalCapabilities::none() } else if matches.is_present("ansi_only") { - Box::new(AnsiTerminal::new(stdout())) + TerminalCapabilities::ansi() } else { - detect_terminal() + TerminalCapabilities::detect() }; // On Windows 10 we need to enable ANSI term explicitly. @@ -132,7 +133,7 @@ impl Arguments { resource_access, dump_events, detect_only, - terminal, + terminal_capabilities, }) } } @@ -200,8 +201,8 @@ Report issues to .", ); let matches = app.get_matches(); - let mut arguments = Arguments::from_matches(&matches).unwrap_or_else(|e| e.exit()); - match process_arguments(size, &mut arguments) { + let arguments = Arguments::from_matches(&matches).unwrap_or_else(|e| e.exit()); + match process_arguments(size, arguments) { Ok(_) => std::process::exit(0), Err(error) => { eprintln!("Error: {}", error); diff --git a/src/resources.rs b/src/resources.rs index 53bed0ef..b1323cc9 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -14,20 +14,8 @@ //! Access to resources referenced from markdown documents. -use failure::Error; -use std::borrow::Cow; -use std::fs::File; -use std::io; -use std::io::prelude::*; -use std::path::Path; use url::Url; -// Required for remote resources -#[cfg(not(feature = "remote_resources"))] -use crate::error::NotSupportedError; -#[cfg(feature = "remote_resources")] -use reqwest; - /// What kind of resources mdcat may access when rendering. /// /// This struct denotes whether mdcat shows inline images from remote URLs or @@ -40,293 +28,257 @@ pub enum ResourceAccess { RemoteAllowed, } -/// A resource referenced from a Markdown document. -pub enum Resource<'a> { - /// A local file, referenced by its *absolute* path. - LocalFile(Cow<'a, Path>), - /// A remote resource, referenced by a URL. - Remote(Url), -} - -/// A non-200 status code from a HTTP request. -#[derive(Debug, Fail)] -#[fail( - display = "Url {} failed with status code {}", - url, - status_code -)] -#[cfg(feature = "remote_resources")] -pub struct HttpStatusError { - /// The URL that was requested - url: Url, - /// The status code. - status_code: reqwest::StatusCode, -} - -impl<'a> Resource<'a> { - /// Obtain a resource from a markdown `reference`. - /// - /// Try to parse `reference` as a URL. If this succeeds assume that - /// `reference` refers to a remote resource and return a `Remote` resource. - /// - /// Otherwise assume that `reference` denotes a local file by its path and - /// return a `LocalFile` resource. If `reference` holds a relative path - /// join it against `base_dir` first. - pub fn from_reference(base_dir: &Path, reference: &'a str) -> Resource<'a> { - if let Ok(url) = Url::parse(reference) { - Resource::Remote(url) - } else { - let path = Path::new(reference); - if path.is_absolute() { - Resource::LocalFile(Cow::Borrowed(path)) - } else { - Resource::LocalFile(Cow::Owned(base_dir.join(path))) - } - } - } - - /// Whether this resource is local. - fn is_local(&self) -> bool { - match *self { - Resource::LocalFile(_) => true, - _ => false, - } - } - - /// Whether we may access this resource under the given access permissions. - pub fn may_access(&self, access: ResourceAccess) -> bool { - match access { - ResourceAccess::RemoteAllowed => true, - ResourceAccess::LocalOnly => self.is_local(), - } - } - - /// Convert this resource into a URL. - /// - /// Return a `Remote` resource as is, and a `LocalFile` as `file:` URL. - pub fn into_url(self) -> Url { +impl ResourceAccess { + /// Whether the resource access permits access to the given `url`. + pub fn permits(&self, url: &Url) -> bool { match self { - Resource::Remote(url) => url, - Resource::LocalFile(path) => Url::parse("file:///") - .expect("Failed to parse file root URL!") - .join(&path.to_string_lossy()) - .unwrap_or_else(|_| panic!(format!("Failed to join root URL with {:?}", path))), - } - } - - /// Extract the local path from this resource. - /// - /// If the resource is a `LocalFile`, or a `file://` URL pointing to a local - /// file return the local path, otherwise return `None`. - pub fn local_path(&'a self) -> Option> { - match *self { - Resource::Remote(ref url) if url.scheme() == "file" && url.host().is_none() => { - Some(Cow::Borrowed(Path::new(url.path()))) - } - Resource::LocalFile(ref path) => Some(Cow::Borrowed(path)), - _ => None, - } - } - - /// Convert this resource to a string. - /// - /// For local resource return the lossy UTF-8 representation of the path, - /// for remote resource the string serialization of the URL. - pub fn as_str(&'a self) -> Cow<'a, str> { - match *self { - Resource::Remote(ref url) => Cow::Borrowed(url.as_str()), - Resource::LocalFile(ref path) => path.to_string_lossy(), - } - } - - /// Read the contents of this resource. - /// - /// Supports local files and HTTP(S) resources. `access` denotes the access - /// permissions. - pub fn read(&self, access: ResourceAccess) -> Result, Error> { - if self.may_access(access) { - match *self { - Resource::Remote(ref url) => read_http(url), - Resource::LocalFile(ref path) => { - let mut buffer = Vec::new(); - File::open(path)?.read_to_end(&mut buffer)?; - Ok(buffer) - } - } - } else { - Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "Remote resources not allowed", - ).into()) + ResourceAccess::LocalOnly if is_local(url) => true, + ResourceAccess::RemoteAllowed => true, + _ => false, } } } -/// Read a resource from HTTP(S). -#[cfg(feature = "remote_resources")] -fn read_http(url: &Url) -> Result, Error> { - // We need to clone "Url" here because for some reason `get` - // claims ownership of Url which we don't have here. - let mut response = reqwest::get(url.clone())?; - if response.status().is_success() { - let mut buffer = Vec::new(); - response.read_to_end(&mut buffer)?; - Ok(buffer) - } else { - Err(HttpStatusError { - url: url.clone(), - status_code: response.status(), - }.into()) - } +pub fn is_local(url: &Url) -> bool { + url.scheme() == "file" && url.to_file_path().is_ok() } -#[cfg(not(feature = "remote_resources"))] -fn read_http(_url: &Url) -> Result, Error> { - Err(NotSupportedError { - what: "remote resources", - }.into()) -} +// /// A non-200 status code from a HTTP request. +// #[derive(Debug, Fail)] +// #[fail( +// display = "Url {} failed with status code {}", +// url, +// status_code +// )] +// #[cfg(feature = "remote_resources")] +// pub struct HttpStatusError { +// /// The URL that was requested +// url: Url, +// /// The status code. +// status_code: reqwest::StatusCode, +// } + +// impl<'a> Resource<'a> { +// /// Obtain a resource from a markdown `reference`. +// /// +// /// Try to parse `reference` as a URL. If this succeeds assume that +// /// `reference` refers to a remote resource and return a `Remote` resource. +// /// +// /// Otherwise assume that `reference` denotes a local file by its path and +// /// return a `LocalFile` resource. If `reference` holds a relative path +// /// join it against `base_dir` first. +// pub fn from_reference(base_dir: &Path, reference: &'a str) -> Resource<'a> { +// if let Ok(url) = Url::parse(reference) { +// Resource::Remote(url) +// } else { +// let path = Path::new(reference); +// if path.is_absolute() { +// Resource::LocalFile(Cow::Borrowed(path)) +// } else { +// Resource::LocalFile(Cow::Owned(base_dir.join(path))) +// } +// } +// } + +// /// Whether this resource is local. +// fn is_local(&self) -> bool { +// match *self { +// Resource::LocalFile(_) => true, +// _ => false, +// } +// } + +// /// Whether we may access this resource under the given access permissions. +// pub fn may_access(&self, access: ResourceAccess) -> bool { +// match access { +// ResourceAccess::RemoteAllowed => true, +// ResourceAccess::LocalOnly => self.is_local(), +// } +// } + +// /// Convert this resource into a URL. +// /// +// /// Return a `Remote` resource as is, and a `LocalFile` as `file:` URL. +// pub fn into_url(self) -> Url { +// match self { +// Resource::Remote(url) => url, +// Resource::LocalFile(path) => Url::parse("file:///") +// .expect("Failed to parse file root URL!") +// .join(&path.to_string_lossy()) +// .unwrap_or_else(|_| panic!(format!("Failed to join root URL with {:?}", path))), +// } +// } + +// /// Extract the local path from this resource. +// /// +// /// If the resource is a `LocalFile`, or a `file://` URL pointing to a local +// /// file return the local path, otherwise return `None`. +// pub fn local_path(&'a self) -> Option> { +// match *self { +// Resource::Remote(ref url) if url.scheme() == "file" && url.host().is_none() => { +// Some(Cow::Borrowed(Path::new(url.path()))) +// } +// Resource::LocalFile(ref path) => Some(Cow::Borrowed(path)), +// _ => None, +// } +// } + +// /// Convert this resource to a string. +// /// +// /// For local resource return the lossy UTF-8 representation of the path, +// /// for remote resource the string serialization of the URL. +// pub fn as_str(&'a self) -> Cow<'a, str> { +// match *self { +// Resource::Remote(ref url) => Cow::Borrowed(url.as_str()), +// Resource::LocalFile(ref path) => path.to_string_lossy(), +// } +// } + +// /// Read the contents of this resource. +// /// +// /// Supports local files and HTTP(S) resources. `access` denotes the access +// /// permissions. +// pub fn read(&self, access: ResourceAccess) -> Result, Error> { +// if self.may_access(access) { +// match *self { +// Resource::Remote(ref url) => read_http(url), +// Resource::LocalFile(ref path) => { +// let mut buffer = Vec::new(); +// File::open(path)?.read_to_end(&mut buffer)?; +// Ok(buffer) +// } +// } +// } else { +// Err(io::Error::new( +// io::ErrorKind::PermissionDenied, +// "Remote resources not allowed", +// ).into()) +// } +// } +// } + +// /// Read a resource from HTTP(S). +// #[cfg(feature = "remote_resources")] +// fn read_http(url: &Url) -> Result, Error> { +// // We need to clone "Url" here because for some reason `get` +// // claims ownership of Url which we don't have here. +// let mut response = reqwest::get(url.clone())?; +// if response.status().is_success() { +// let mut buffer = Vec::new(); +// response.read_to_end(&mut buffer)?; +// Ok(buffer) +// } else { +// Err(HttpStatusError { +// url: url.clone(), +// status_code: response.status(), +// }.into()) +// } +// } + +// #[cfg(not(feature = "remote_resources"))] +// fn read_http(_url: &Url) -> Result, Error> { +// Err(NotSupportedError { +// what: "remote resources", +// }.into()) +// } #[cfg(test)] mod tests { - use super::Resource::*; - use super::ResourceAccess::*; use super::*; - use std::borrow::Cow::Borrowed; - mod may_access { - use super::*; - - #[test] - fn local_resource() { - let resource = LocalFile(Borrowed(Path::new("/foo/bar"))); - assert!(resource.may_access(LocalOnly)); - assert!(resource.may_access(RemoteAllowed)); - } - - #[test] - fn remote_resource() { - let resource = Remote("http://example.com".parse().unwrap()); - assert!(!resource.may_access(LocalOnly)); - assert!(resource.may_access(RemoteAllowed)); - } + #[test] + fn resource_access_permits_local_resource() { + let resource = Url::parse("file:///foo/bar").unwrap(); + assert!(ResourceAccess::LocalOnly.permits(&resource)); + assert!(ResourceAccess::RemoteAllowed.permits(&resource)); } - mod into_url { - use super::*; - - #[test] - fn local_resource() { - let resource = LocalFile(Borrowed(Path::new("/foo/bar"))); - assert_eq!(resource.into_url(), "file:///foo/bar".parse().unwrap()); - } - - #[test] - fn remote_resource() { - let url = "https://www.example.com/with/path?and&query" - .parse::() - .unwrap(); - assert_eq!(Remote(url.clone()).into_url(), url); - } + #[test] + fn resource_access_permits_remote_file_url() { + let resource = Url::parse("file://example.com/foo/bar").unwrap(); + assert!(!ResourceAccess::LocalOnly.permits(&resource)); + assert!(ResourceAccess::RemoteAllowed.permits(&resource)); } - mod local_path { - use super::*; - - #[test] - fn local_path_of_remote_resource() { - let resource = Resource::Remote("http://example.com".parse().unwrap()); - assert_eq!(resource.local_path(), None); - } - - #[test] - fn local_path_of_file_url() { - let resource = Resource::Remote("file:///spam/with/eggs".parse().unwrap()); - let path = resource.local_path(); - assert!(path.is_some()); - assert_eq!(path.unwrap(), Path::new("/spam/with/eggs")); - } - - #[test] - fn local_path_of_local_resource() { - let path = Path::new("/foo/bar"); - let resource = Resource::LocalFile(Borrowed(path)); - assert_eq!(resource.local_path().unwrap(), path); - } + #[test] + fn resource_access_permits_https_url() { + let resource = Url::parse("https:///foo/bar").unwrap(); + assert!(!ResourceAccess::LocalOnly.permits(&resource)); + assert!(ResourceAccess::RemoteAllowed.permits(&resource)); } - mod read { - use super::*; - use std::error::Error; - - #[test] - fn remote_resource_fails_with_permission_denied_without_access() { - let resource = Resource::Remote( - "https://eu.httpbin.org/bytes/100" - .parse() - .expect("No valid URL"), - ); - let result = resource.read(ResourceAccess::LocalOnly); - assert!(result.is_err(), "Unexpected success: {:?}", result); - let error = match result.unwrap_err().downcast::() { - Ok(e) => e, - Err(error) => panic!("Not an IO error: {:?}", error), - }; - - assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); - assert_eq!(error.description(), "Remote resources not allowed"); - } - - #[cfg(feature = "remote_resources")] - #[test] - fn remote_resource_fails_when_status_404() { - let url: Url = "https://eu.httpbin.org/status/404" - .parse() - .expect("No valid URL"); - let resource = Resource::Remote(url.clone()); - let result = resource.read(ResourceAccess::RemoteAllowed); - assert!(result.is_err(), "Unexpected success: {:?}", result); - let error = result - .unwrap_err() - .downcast::() - .expect("Not an IO error"); - assert_eq!(error.status_code, reqwest::StatusCode::NOT_FOUND); - assert_eq!(error.url, url); - } - - #[cfg(feature = "remote_resources")] - #[test] - fn remote_resource_returns_content_when_status_200() { - let resource = Resource::Remote( - "https://eu.httpbin.org/bytes/100" - .parse() - .expect("No valid URL"), - ); - let result = resource.read(ResourceAccess::RemoteAllowed); - assert!(result.is_ok(), "Unexpected error: {:?}", result); - assert_eq!(result.unwrap().len(), 100); - } - - #[cfg(not(feature = "remote_resources"))] - #[test] - fn remote_resource_returns_not_supported_if_feature_is_disabled() { - let resource = Resource::Remote( - "https://eu.httpbin.org/bytes/100" - .parse() - .expect("No valid URL"), - ); - let result = resource.read(ResourceAccess::RemoteAllowed); - assert!(result.is_err(), "Unexpected success: {:?}", result); - let error = result - .unwrap_err() - .downcast::() - .expect("Not a NotSupportedError!"); - assert_eq!( - error, - NotSupportedError { - what: "remote resources" - } - ); - } - } + // mod read { + // use super::*; + // use std::error::Error; + + // #[test] + // fn remote_resource_fails_with_permission_denied_without_access() { + // let resource = Resource::Remote( + // "https://eu.httpbin.org/bytes/100" + // .parse() + // .expect("No valid URL"), + // ); + // let result = resource.read(ResourceAccess::LocalOnly); + // assert!(result.is_err(), "Unexpected success: {:?}", result); + // let error = match result.unwrap_err().downcast::() { + // Ok(e) => e, + // Err(error) => panic!("Not an IO error: {:?}", error), + // }; + + // assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + // assert_eq!(error.description(), "Remote resources not allowed"); + // } + + // #[cfg(feature = "remote_resources")] + // #[test] + // fn remote_resource_fails_when_status_404() { + // let url: Url = "https://eu.httpbin.org/status/404" + // .parse() + // .expect("No valid URL"); + // let resource = Resource::Remote(url.clone()); + // let result = resource.read(ResourceAccess::RemoteAllowed); + // assert!(result.is_err(), "Unexpected success: {:?}", result); + // let error = result + // .unwrap_err() + // .downcast::() + // .expect("Not an IO error"); + // assert_eq!(error.status_code, reqwest::StatusCode::NOT_FOUND); + // assert_eq!(error.url, url); + // } + + // #[cfg(feature = "remote_resources")] + // #[test] + // fn remote_resource_returns_content_when_status_200() { + // let resource = Resource::Remote( + // "https://eu.httpbin.org/bytes/100" + // .parse() + // .expect("No valid URL"), + // ); + // let result = resource.read(ResourceAccess::RemoteAllowed); + // assert!(result.is_ok(), "Unexpected error: {:?}", result); + // assert_eq!(result.unwrap().len(), 100); + // } + + // #[cfg(not(feature = "remote_resources"))] + // #[test] + // fn remote_resource_returns_not_supported_if_feature_is_disabled() { + // let resource = Resource::Remote( + // "https://eu.httpbin.org/bytes/100" + // .parse() + // .expect("No valid URL"), + // ); + // let result = resource.read(ResourceAccess::RemoteAllowed); + // assert!(result.is_err(), "Unexpected success: {:?}", result); + // let error = result + // .unwrap_err() + // .downcast::() + // .expect("Not a NotSupportedError!"); + // assert_eq!( + // error, + // NotSupportedError { + // what: "remote resources" + // } + // ); + // } + // } } diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index d4734dbd..d574c36a 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -12,82 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! A standard Ansi terminal with no special features. - -use failure::Error; -use std::io; -use std::io::Write; - -use crate::error::NotSupportedError; -use crate::resources::{Resource, ResourceAccess}; -use crate::terminal::size::Size; -use crate::terminal::write::Terminal; - -/// A simple ANSI terminal with support for basic ANSI styles. -/// -/// This represents most ordinary terminal emulators. -pub struct AnsiTerminal { - writer: W, -} - -impl AnsiTerminal { - /// Create a new ANSI terminal for th given writer. - pub fn new(writer: W) -> AnsiTerminal { - AnsiTerminal { writer } - } - - /// Write an OSC `command` to this terminal. - pub fn write_osc(&mut self, command: &str) -> io::Result<()> { - self.writer.write_all(&[0x1b, 0x5d])?; - self.writer.write_all(command.as_bytes())?; - self.writer.write_all(&[0x07])?; - Ok(()) - } - - /// Write a CSI SGR `command` to this terminal. - /// - /// See . - pub fn write_sgr(&mut self, command: &str) -> io::Result<()> { - self.writer.write_all(&[0x1b, 0x5b])?; - self.writer.write_all(command.as_bytes())?; - self.writer.write_all(&[0x6d])?; - Ok(()) - } -} - -impl Terminal for AnsiTerminal { - type TerminalWrite = W; - - fn name(&self) -> &'static str { - "ANSI" - } - - fn write(&mut self) -> &mut W { - &mut self.writer - } - - fn supports_styles(&self) -> bool { - true - } - - fn set_link(&mut self, _destination: &str) -> Result<(), Error> { - Err(NotSupportedError { - what: "inline links", - })? - } - - fn set_mark(&mut self) -> Result<(), Error> { - Err(NotSupportedError { what: "marks" })? - } - - fn write_inline_image( - &mut self, - _max_size: Size, - _resources: &Resource, - _access: ResourceAccess, - ) -> Result<(), Error> { - Err(NotSupportedError { - what: "inline images", - })? +//! Standard ANSI styling. + +use ansi_term::Style; +use std::io::{Result, Write}; + +/// Access to a terminal’s basic ANSI styling functionality. +pub struct AnsiStyle; + +impl AnsiStyle { + /// Write styled text to the given writer. + pub fn write_styled>( + &self, + write: &mut W, + style: &Style, + text: V, + ) -> Result<()> { + write!(write, "{}", style.paint(text.as_ref())) } } diff --git a/src/terminal/dumb.rs b/src/terminal/dumb.rs deleted file mode 100644 index a9939f35..00000000 --- a/src/terminal/dumb.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! A terminal with no features. - -use failure::Error; -use std::io::Write; - -use crate::error::NotSupportedError; -use crate::resources::{Resource, ResourceAccess}; -use crate::terminal::size::Size; -use crate::terminal::write::Terminal; - -/// A dumb terminal with no style support. -/// -/// With this terminal mdcat will render no special formatting at all. Use -/// when piping to other programs or when the terminal does not even support -/// ANSI codes. -pub struct DumbTerminal { - writer: W, -} - -impl DumbTerminal { - /// Create a new bump terminal for the given writer. - pub fn new(writer: W) -> DumbTerminal { - DumbTerminal { writer } - } -} - -impl Terminal for DumbTerminal { - type TerminalWrite = W; - - fn name(&self) -> &'static str { - "dumb" - } - - fn write(&mut self) -> &mut W { - &mut self.writer - } - - fn supports_styles(&self) -> bool { - false - } - - fn set_link(&mut self, _destination: &str) -> Result<(), Error> { - Err(NotSupportedError { - what: "inline links", - })? - } - - fn set_mark(&mut self) -> Result<(), Error> { - Err(NotSupportedError { what: "marks" })? - } - - fn write_inline_image( - &mut self, - _max_size: Size, - _resources: &Resource, - _access: ResourceAccess, - ) -> Result<(), Error> { - Err(NotSupportedError { - what: "inline images", - })? - } -} diff --git a/src/terminal/highlighting.rs b/src/terminal/highlighting.rs index 6c93c3d2..b17a03ac 100644 --- a/src/terminal/highlighting.rs +++ b/src/terminal/highlighting.rs @@ -14,8 +14,8 @@ //! Tools for syntax highlighting. +use super::ansi::AnsiStyle; use ansi_term::Colour; -use crate::terminal::write::{write_styled, Terminal}; use failure::Error; use std::io::Write; use syntect::highlighting::{FontStyle, Style}; @@ -36,7 +36,8 @@ use syntect::highlighting::{FontStyle, Style}; /// Furthermore we completely ignore any background colour settings, to avoid /// conflicts with the terminal colour themes. pub fn write_as_ansi( - terminal: &mut dyn Terminal, + writer: &mut W, + ansi: &AnsiStyle, regions: &[(Style, &str)], ) -> Result<(), Error> { for &(style, text) in regions { @@ -69,7 +70,7 @@ pub fn write_as_ansi( ansi_style.is_bold = font.contains(FontStyle::BOLD); ansi_style.is_italic = font.contains(FontStyle::ITALIC); ansi_style.is_underline = font.contains(FontStyle::UNDERLINE); - write_styled(terminal, &ansi_style, text)?; + ansi.write_styled(writer, &ansi_style, text)?; } Ok(()) diff --git a/src/terminal/iterm2.rs b/src/terminal/iterm2.rs index 179464b6..299dd37d 100644 --- a/src/terminal/iterm2.rs +++ b/src/terminal/iterm2.rs @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Iterm2 specific functions +//! The iTerm2 terminal. +//! +//! iTerm2 is a powerful macOS terminal emulator with many formatting +//! features, including images and inline links. +//! +//! See for more information. use base64; use failure::Error; @@ -23,24 +28,6 @@ use std::io; use std::io::Write; use std::os::unix::ffi::OsStrExt; -use crate::error::NotSupportedError; -use crate::magic; -use crate::resources::{Resource, ResourceAccess}; -use crate::svg; -use crate::terminal::ansi::AnsiTerminal; -use crate::terminal::size::Size; -use crate::terminal::write::Terminal; - -/// The iTerm2 terminal. -/// -/// iTerm2 is a powerful macOS terminal emulator with many formatting -/// features, including images and inline links. -/// -/// See for more information. -pub struct ITerm2 { - ansi: AnsiTerminal, -} - /// Whether we run inside iTerm2 or not. pub fn is_iterm2() -> bool { std::env::var("TERM_PROGRAM") @@ -48,87 +35,87 @@ pub fn is_iterm2() -> bool { .unwrap_or(false) } -impl ITerm2 { - /// Create an iTerm2 terminal over an underlying ANSI terminal. - pub fn new(ansi: AnsiTerminal) -> ITerm2 { - ITerm2 { ansi } - } - - fn write_image_contents>( - &mut self, - name: S, - contents: &[u8], - ) -> io::Result<()> { - self.ansi.write_osc(&format!( - "1337;File=name={};inline=1:{}", - base64::encode(name.as_ref().as_bytes()), - base64::encode(contents) - )) - } - - /// Write an iterm2 inline image. - /// - /// `name` is the file name of the image, and `contents` holds the image - /// contents. - pub fn write_inline_image>( - &mut self, - name: S, - contents: &[u8], - ) -> Result<(), Error> { - let mime = magic::detect_mime_type(contents)?; - match (mime.type_(), mime.subtype()) { - (mime::IMAGE, mime::PNG) - | (mime::IMAGE, mime::GIF) - | (mime::IMAGE, mime::JPEG) - | (mime::IMAGE, mime::BMP) => self - .write_image_contents(name, contents) - .map_err(Into::into), - (mime::IMAGE, subtype) if subtype.as_str() == "svg" => { - let png = svg::render_svg(contents)?; - self.write_image_contents(name, &png).map_err(Into::into) - } - _ => Err(NotSupportedError { - what: "inline image with mimetype", - }.into()), - } - } -} - -impl Terminal for ITerm2 { - type TerminalWrite = W; - - fn name(&self) -> &'static str { - "iTerm2" - } - - fn write(&mut self) -> &mut W { - self.ansi.write() - } - - fn supports_styles(&self) -> bool { - self.ansi.supports_styles() - } - - fn set_link(&mut self, destination: &str) -> Result<(), Error> { - self.ansi.write_osc(&format!("8;;{}", destination))?; - Ok(()) - } - - fn set_mark(&mut self) -> Result<(), Error> { - self.ansi.write_osc("1337;SetMark")?; - Ok(()) - } - - fn write_inline_image( - &mut self, - _max_size: Size, - resource: &Resource, - access: ResourceAccess, - ) -> Result<(), Error> { - resource.read(access).and_then(|contents| { - self.write_inline_image(resource.as_str().as_ref(), &contents) - .map_err(Into::into) - })?; - Ok(()) - } -} +// impl ITerm2 { +// /// Create an iTerm2 terminal over an underlying ANSI terminal. +// pub fn new(ansi: AnsiTerminal) -> ITerm2 { +// ITerm2 { ansi } +// } + +// fn write_image_contents>( +// &mut self, +// name: S, +// contents: &[u8], +// ) -> io::Result<()> { +// self.ansi.write_osc(&format!( +// "1337;File=name={};inline=1:{}", +// base64::encode(name.as_ref().as_bytes()), +// base64::encode(contents) +// )) +// } + +// /// Write an iterm2 inline image. +// /// +// /// `name` is the file name of the image, and `contents` holds the image +// /// contents. +// pub fn write_inline_image>( +// &mut self, +// name: S, +// contents: &[u8], +// ) -> Result<(), Error> { +// let mime = magic::detect_mime_type(contents)?; +// match (mime.type_(), mime.subtype()) { +// (mime::IMAGE, mime::PNG) +// | (mime::IMAGE, mime::GIF) +// | (mime::IMAGE, mime::JPEG) +// | (mime::IMAGE, mime::BMP) => self +// .write_image_contents(name, contents) +// .map_err(Into::into), +// (mime::IMAGE, subtype) if subtype.as_str() == "svg" => { +// let png = svg::render_svg(contents)?; +// self.write_image_contents(name, &png).map_err(Into::into) +// } +// _ => Err(NotSupportedError { +// what: "inline image with mimetype", +// }.into()), +// } +// } +// } + +// impl Terminal for ITerm2 { +// type TerminalWrite = W; + +// fn name(&self) -> &'static str { +// "iTerm2" +// } + +// fn write(&mut self) -> &mut W { +// self.ansi.write() +// } + +// fn supports_styles(&self) -> bool { +// self.ansi.supports_styles() +// } + +// fn set_link(&mut self, destination: &str) -> Result<(), Error> { +// self.ansi.write_osc(&format!("8;;{}", destination))?; +// Ok(()) +// } + +// fn set_mark(&mut self) -> Result<(), Error> { +// self.ansi.write_osc("1337;SetMark")?; +// Ok(()) +// } + +// fn write_inline_image( +// &mut self, +// _max_size: Size, +// resource: &Resource, +// access: ResourceAccess, +// ) -> Result<(), Error> { +// resource.read(access).and_then(|contents| { +// self.write_inline_image(resource.as_str().as_ref(), &contents) +// .map_err(Into::into) +// })?; +// Ok(()) +// } +// } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index aa39a010..dabd879a 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -15,62 +15,136 @@ //! Terminal utilities. // Support modules for terminal writing. -mod highlighting; -mod size; -mod write; -// Terminal implementations; mod ansi; -mod dumb; -#[cfg(feature = "iterm2")] -mod iterm2; +pub mod highlighting; +mod osc; +mod size; + #[cfg(feature = "terminology")] mod terminology; -mod vte50; -use std::io; +pub use self::ansi::AnsiStyle; +pub use self::size::Size as TerminalSize; -#[cfg(feature = "iterm2")] -use self::iterm2::*; -#[cfg(feature = "terminology")] -use self::terminology::*; -use self::vte50::*; - -// Export types. -pub use self::ansi::AnsiTerminal; -pub use self::dumb::DumbTerminal; -pub use self::highlighting::write_as_ansi; -pub use self::size::Size; -pub use self::write::{write_styled, Terminal}; -pub use crate::error::IgnoreNotSupported; - -/// Detect the terminal to use. -/// -/// Check for various environment variables that identify specific terminal -/// emulators with more advanced formatting features. -/// -/// If we can't detect any such emulator assume only standard ANSI colour -/// and formatting capabilities. -pub fn detect_terminal() -> Box> { - let ansi = AnsiTerminal::new(io::stdout()); - // Pattern matching lets use feature-switch branches, depending on - // enabled terminal support. In an if chain we can't do this, so that's - // why we have this weird match here. Note: Don't use true here because - // that makes clippy complain. - match 1 { - #[cfg(feature = "iterm2")] - _ if iterm2::is_iterm2() => - { - Box::new(ITerm2::new(ansi)) +/// The capability of basic styling. +pub enum StyleCapability { + /// The terminal supports no styles. + None, + /// The terminal supports ANSI styles. + Ansi(AnsiStyle), +} + +/// How the terminal supports inline links. +pub enum LinkCapability { + /// The terminal does not support inline links. + None, + /// The terminal supports [OSC 8] inline links. + /// + /// [OSC 8]: https://git.io/vd4ee + #[cfg(feature = "osc8_links")] + OSC8(self::osc::OSC8Links), +} + +/// The capability of the terminal to set marks. +pub enum MarkCapability { + /// The terminal can't set marks. + None, +} + +/// The capability of the terminal to write images inline. +pub enum ImageCapability { + /// The terminal can't write images inline. + None, + /// The terminal understands the terminology way of inline images. + #[cfg(feature = "terminology")] + Terminology(terminology::TerminologyImages), +} + +/// The capabilities of a terminal. +pub struct TerminalCapabilities { + /// How do we call this terminal? + pub name: String, + /// How the terminal supports basic styling. + pub style: StyleCapability, + /// How the terminal supports links. + pub links: LinkCapability, + /// How the terminal supports images. + pub image: ImageCapability, + /// How the terminal supports marks. + pub marks: MarkCapability, +} + +impl TerminalCapabilities { + /// A terminal which supports nothing. + pub fn none() -> TerminalCapabilities { + TerminalCapabilities { + name: "dumb".to_string(), + style: StyleCapability::None, + links: LinkCapability::None, + image: ImageCapability::None, + marks: MarkCapability::None, + } + } + + /// A terminal with basic ANSI formatting only. + pub fn ansi() -> TerminalCapabilities { + TerminalCapabilities { + name: "Ansi".to_string(), + style: StyleCapability::Ansi(AnsiStyle), + links: LinkCapability::None, + image: ImageCapability::None, + marks: MarkCapability::None, } - #[cfg(feature = "terminology")] - _ if terminology::is_terminology() => - { - Box::new(Terminology::new(ansi)) + } + + /// Detect the capabilities of the current terminal. + pub fn detect() -> TerminalCapabilities { + // Pattern matching lets use feature-switch branches, depending on + // enabled terminal support. In an if chain we can't do this, so that's + // why we have this weird match here. Note: Don't use true here because + // that makes clippy complain. + match 1 { + // #[cfg(feature = "iterm2")] + // _ if iterm2::is_iterm2() => + // { + // Box::new(ITerm2::new(ansi)) + // } + #[cfg(feature = "terminology")] + _ if self::terminology::is_terminology() => + { + TerminalCapabilities { + name: "Terminology".to_string(), + style: StyleCapability::Ansi(AnsiStyle), + links: LinkCapability::OSC8(self::osc::OSC8Links), + image: ImageCapability::Terminology(self::terminology::TerminologyImages), + marks: MarkCapability::None, + } + } + #[cfg(feature = "vte50")] + _ if get_vte_version().filter(|&v| v >= (50, 0)).is_some() => + { + TerminalCapabilities { + name: "VTE 50".to_string(), + style: StyleCapability::Ansi(AnsiStyle), + links: LinkCapability::OSC8(self::osc::OSC8Links), + image: ImageCapability::None, + marks: MarkCapability::None, + } + } + _ => TerminalCapabilities::ansi(), } - _ => match vte50::get_vte_version() { - Some(version) if version >= (50, 0) => Box::new(VTE50Terminal::new(ansi)), - _ => Box::new(ansi), - }, } } + +/// Get the version of the underlying VTE terminal if any. +#[cfg(feature = "vte50")] +pub fn get_vte_version() -> Option<(u8, u8)> { + std::env::var("VTE_VERSION").ok().and_then(|value| { + value[..2] + .parse::() + .into_iter() + .zip(value[2..4].parse::()) + .next() + }) +} diff --git a/src/terminal/osc.rs b/src/terminal/osc.rs new file mode 100644 index 00000000..8e05789a --- /dev/null +++ b/src/terminal/osc.rs @@ -0,0 +1,39 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! OSC commands on terminals. + +use std::io::{Result, Write}; + +/// Write an OSC `command` to this terminal. +pub fn write_osc(writer: &mut W, command: &str) -> Result<()> { + writer.write_all(&[0x1b, 0x5d])?; + writer.write_all(command.as_bytes())?; + writer.write_all(&[0x07])?; + Ok(()) +} + +#[cfg(feature = "osc8_links")] +pub struct OSC8Links; + +#[cfg(feature = "osc8_links")] +impl OSC8Links { + pub fn set_link(&self, writer: &mut W, destination: &str) -> Result<()> { + write_osc(writer, &format!("8;;{}", destination)) + } + + pub fn clear_link(&self, writer: &mut W) -> Result<()> { + self.set_link(writer, "") + } +} diff --git a/src/terminal/terminology.rs b/src/terminal/terminology.rs index ce5d396e..193cddf6 100644 --- a/src/terminal/terminology.rs +++ b/src/terminal/terminology.rs @@ -16,11 +16,9 @@ //! //! [Terminology]: http://terminolo.gy -use failure::Error; -use std::io::{ErrorKind, Write}; - -use crate::resources::{Resource, ResourceAccess}; -use crate::terminal::*; +use super::TerminalSize; +use std::io::{Result, Write}; +use url::Url; /// Whether we run in terminology or not. pub fn is_terminology() -> bool { @@ -29,54 +27,17 @@ pub fn is_terminology() -> bool { .unwrap_or(false) } -/// The Terminology terminal. -/// -/// Terminology is a terminal written for the Enlightenment window manager -/// using the powerful EFL libraries. It supports inline links and inline -/// images. -/// -/// See for more information. -pub struct Terminology { - ansi: AnsiTerminal, -} - -impl Terminology { - /// Create a Terminology terminal over an underlying ANSI terminal. - pub fn new(ansi: AnsiTerminal) -> Terminology { - Terminology { ansi } - } -} - -impl Terminal for Terminology { - type TerminalWrite = W; - - fn name(&self) -> &'static str { - "Terminology" - } - - fn write(&mut self) -> &mut W { - self.ansi.write() - } +/// Provides access to printing images for Terminology. +pub struct TerminologyImages; - fn supports_styles(&self) -> bool { - self.ansi.supports_styles() - } - - fn set_link(&mut self, destination: &str) -> Result<(), Error> { - self.ansi.write_osc(&format!("8;;{}", destination))?; - Ok(()) - } - - fn set_mark(&mut self) -> Result<(), Error> { - self.ansi.set_mark() - } - - fn write_inline_image( +impl TerminologyImages { + /// Write an inline image for Terminology. + pub fn write_inline_image( &mut self, - max_size: Size, - resource: &Resource, - resource_access: ResourceAccess, - ) -> Result<(), Error> { + writer: &mut W, + max_size: TerminalSize, + url: &Url, + ) -> Result<()> { // Terminology escape sequence is like: set texture to path, then draw a // rectangle of chosen character to be replaced by the given texture. // Documentation gives the following C example: @@ -89,34 +50,29 @@ impl Terminal for Terminology { // We need to compute image proportion to draw the appropriate // rectangle. If we can't compute the image proportion (e.g. it's an // external URL), we fallback to a rectangle that is half of the screen. - if resource.may_access(resource_access) { - let columns = max_size.width; - let lines = resource - .local_path() - .and_then(|path| immeta::load_from_file(path).ok()) - .map(|m| { - let d = m.dimensions(); - let (w, h) = (f64::from(d.width), f64::from(d.height)); - // We divide by 2 because terminal cursor/font most likely has a - // 1:2 proportion - (h * (columns / 2) as f64 / w) as usize - }).unwrap_or(max_size.height / 2); - - let mut command = format!("\x1b}}ic#{};{};{}\x00", columns, lines, resource.as_str()); - for _ in 0..lines { - command.push_str("\x1b}ib\x00"); - for _ in 0..columns { - command.push('#'); - } - command.push_str("\x1b}ie\x00\n"); + let columns = max_size.width; + + let lines = Some(url) + .filter(|url| url.scheme() == "file") + .and_then(|url| url.to_file_path().ok()) + .and_then(|path| immeta::load_from_file(path).ok()) + .map(|m| { + let d = m.dimensions(); + let (w, h) = (f64::from(d.width), f64::from(d.height)); + // We divide by 2 because terminal cursor/font most likely has a + // 1:2 proportion + (h * (columns / 2) as f64 / w) as usize + }).unwrap_or(max_size.height / 2); + + let mut command = format!("\x1b}}ic#{};{};{}\x00", columns, lines, url.as_str()); + for _ in 0..lines { + command.push_str("\x1b}ib\x00"); + for _ in 0..columns { + command.push('#'); } - self.ansi.write().write_all(command.as_bytes())?; - Ok(()) - } else { - Err( - std::io::Error::new(ErrorKind::PermissionDenied, "Remote resources not allowed") - .into(), - ) + command.push_str("\x1b}ie\x00\n"); } + writer.write_all(command.as_bytes())?; + Ok(()) } } diff --git a/src/terminal/vte50.rs b/src/terminal/vte50.rs deleted file mode 100644 index df7c6ce8..00000000 --- a/src/terminal/vte50.rs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! VTE newer than 50. - -use failure::Error; -use std::io::Write; - -use crate::resources::{Resource, ResourceAccess}; -use crate::terminal::ansi::AnsiTerminal; -use crate::terminal::size::Size; -use crate::terminal::write::Terminal; - -/// Get the version of VTE underlying this terminal. -/// -/// Return `(minor, patch)` if this terminal uses VTE, otherwise return `None`. -pub fn get_vte_version() -> Option<(u8, u8)> { - std::env::var("VTE_VERSION").ok().and_then(|value| { - value[..2] - .parse::() - .into_iter() - .zip(value[2..4].parse::()) - .next() - }) -} - -/// A generic terminal based on a modern VTE (>= 50) version. -/// -/// VTE is Gnome library for terminal emulators. It powers some notable -/// terminal emulators like Gnome Terminal, and embedded terminals in -/// applications like GEdit. -/// -/// VTE 0.50 or newer support inline links. Older versions do not; we -/// recognize these as `BasicAnsi`. -pub struct VTE50Terminal { - ansi: AnsiTerminal, -} - -impl VTE50Terminal { - /// Create a VTE 50 terminal over an underlying ANSI terminal. - pub fn new(ansi: AnsiTerminal) -> VTE50Terminal { - VTE50Terminal { ansi } - } -} - -impl Terminal for VTE50Terminal { - type TerminalWrite = W; - - fn name(&self) -> &'static str { - "VTE 50" - } - - fn write(&mut self) -> &mut W { - self.ansi.write() - } - - fn supports_styles(&self) -> bool { - self.ansi.supports_styles() - } - - fn set_link(&mut self, destination: &str) -> Result<(), Error> { - self.ansi.write_osc(&format!("8;;{}", destination))?; - Ok(()) - } - - fn set_mark(&mut self) -> Result<(), Error> { - self.ansi.set_mark() - } - - fn write_inline_image( - &mut self, - max_size: Size, - resources: &Resource, - access: ResourceAccess, - ) -> Result<(), Error> { - self.ansi.write_inline_image(max_size, resources, access) - } -} diff --git a/src/terminal/write.rs b/src/terminal/write.rs deleted file mode 100644 index 0ced771a..00000000 --- a/src/terminal/write.rs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Writer for terminals. - -use ansi_term::Style; -use crate::resources::{Resource, ResourceAccess}; -use crate::terminal::size::Size; -use failure::Error; -use std::io::Write; - -/// Write to terminals. -pub trait Terminal { - /// The associated writer of this terminal. - type TerminalWrite: Write; - - /// Get a descriptive name for this terminal. - fn name(&self) -> &str; - - /// Get a writer for this terminal. - fn write(&mut self) -> &mut Self::TerminalWrite; - - /// Whether this terminal supports styles. - fn supports_styles(&self) -> bool; - - /// Set a link to the given destination on the terminal. - /// - /// To stop a link write a link with an empty destination. - /// - /// The default implementation errors with `NotSupportedError`. - fn set_link(&mut self, destination: &str) -> Result<(), Error>; - - /// Set a jump mark on the terminal. - /// - /// The default implementation errors with `NotSupportedError`. - fn set_mark(&mut self) -> Result<(), Error>; - - /// Write an inline image from the given resource to the terminal. - /// - /// The default implementation errors with `NotSupportedError`. - fn write_inline_image( - &mut self, - max_size: Size, - resource: &Resource, - access: ResourceAccess, - ) -> Result<(), Error>; -} - -/// Write a styled text to a `terminal`. -/// -/// If the terminal supports styles use `style` to paint `text`, otherwise just -/// write `text` and ignore `style`. -pub fn write_styled>( - terminal: &mut dyn Terminal, - style: &Style, - text: V, -) -> Result<(), Error> { - if terminal.supports_styles() { - write!(terminal.write(), "{}", style.paint(text.as_ref()))?; - } else { - write!(terminal.write(), "{}", text.as_ref())?; - } - Ok(()) -} diff --git a/tests/formatting.rs b/tests/formatting.rs index 4905ab5c..ffc9c2d5 100644 --- a/tests/formatting.rs +++ b/tests/formatting.rs @@ -41,9 +41,9 @@ fn format_ansi_to_html(markdown: &str) -> String { let syntax_set = SyntaxSet::load_defaults_newlines(); let wd = std::env::current_dir().expect("No working directory"); let parser = Parser::new(markdown); - let mut terminal = mdcat::AnsiTerminal::new(child.stdin.unwrap()); mdcat::push_tty( - &mut terminal, + &mut child.stdin.unwrap(), + mdcat::TerminalCapabilities::ansi(), size, parser, &wd, From 832baab06875cd6367e30ab79226678a5fe33683 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Tue, 30 Oct 2018 22:10:44 +0100 Subject: [PATCH 02/12] Make resource use optional Fix compiler warnings with --no-default-features by removing unused code. --- Cargo.toml | 10 ++++++---- src/lib.rs | 31 ++++++++++++++++++++++--------- src/resources.rs | 7 +++++-- src/terminal/mod.rs | 3 ++- 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aa813a1b..f7f9ad62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,20 +17,22 @@ travis-ci = { repository = "lunaryorn/mdcat" } [features] default = ["vte50", "iterm2", "terminology"] +resources = ["url"] + # Special terminal features -osc8_links = [] +osc8_links = ["resources"] # Terminal emulators -iterm2 = ["osc8_links", "mime", "base64"] -terminology = ["osc8_links", "immeta"] +iterm2 = ["osc8_links", "resources", "mime", "base64"] +terminology = ["osc8_links", "resources", "immeta"] vte50 = ["osc8_links"] [dependencies] failure = "^0.1" term_size = "^0.3" -url = "^1.7" ansi_term = "^0.11" +url = {version = "^1.7", optional = true} reqwest = {version = "^0.9", optional = true} mime = {version = "^0.3", optional = true} base64 = {version = "^0.9", optional = true} diff --git a/src/lib.rs b/src/lib.rs index 9008f01f..83e61189 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,12 +34,14 @@ #[cfg(feature = "terminology")] extern crate immeta; +#[cfg(feature = "resources")] +extern crate url; + extern crate ansi_term; extern crate failure; extern crate pulldown_cmark; extern crate syntect; extern crate term_size; -extern crate url; use ansi_term::{Colour, Style}; use failure::Error; @@ -53,6 +55,8 @@ use std::path::Path; use syntect::easy::HighlightLines; use syntect::highlighting::{Theme, ThemeSet}; use syntect::parsing::SyntaxSet; + +#[cfg(feature = "resources")] use url::Url; mod resources; @@ -142,14 +146,16 @@ struct Link<'a> { } /// Input context. -struct InputContext<'a> { +#[cfg(feature = "resources")] +struct ResourceContext<'a> { /// The base directory, to resolve relative paths. base_dir: &'a Path, /// What resources we may access when processing markdown. resource_access: ResourceAccess, } -impl<'a> InputContext<'a> { +#[cfg(feature = "resources")] +impl<'a> ResourceContext<'a> { /// Resolve a reference in the input. /// /// If `reference` parses as URL return the parsed URL. Otherwise assume @@ -239,8 +245,9 @@ struct ImageContext { /// Context for TTY rendering. struct Context<'a, W: Write + 'a> { + #[cfg(feature = "resources")] /// Context for input. - input: InputContext<'a>, + resources: ResourceContext<'a>, /// Context for output. output: OutputContext<'a, W>, /// Context for styling @@ -269,8 +276,14 @@ impl<'a, W: Write> Context<'a, W> { syntax_set: SyntaxSet, theme: &'a Theme, ) -> Context<'a, W> { + #[cfg(not(feature = "resources"))] + { + drop(base_dir); + drop(resource_access) + } Context { - input: InputContext { + #[cfg(feature = "resources")] + resources: ResourceContext { base_dir, resource_access, }, @@ -590,7 +603,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err match ctx.output.capabilities.links { #[cfg(feature = "osc8_links")] LinkCapability::OSC8(ref osc8) => { - if let Some(url) = ctx.input.resolve_reference(&destination) { + if let Some(url) = ctx.resources.resolve_reference(&destination) { osc8.set_link(ctx.output.writer, url.as_str())?; ctx.links.inside_inline_link = true; } @@ -604,9 +617,9 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err Image(link, _title) => match ctx.output.capabilities.image { #[cfg(feature = "terminology")] ImageCapability::Terminology(ref mut terminology) => { - let access = ctx.input.resource_access; + let access = ctx.resources.resource_access; if let Some(url) = ctx - .input + .resources .resolve_reference(&link) .filter(|url| access.permits(url)) { @@ -685,7 +698,7 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E LinkCapability::OSC8(ref osc8) => { osc8.clear_link(ctx.output.writer)?; } - _ => {} + LinkCapability::None => {} } ctx.links.inside_inline_link = false; } else { diff --git a/src/resources.rs b/src/resources.rs index b1323cc9..9c6bec6d 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -14,6 +14,7 @@ //! Access to resources referenced from markdown documents. +#[cfg(feature = "resources")] use url::Url; /// What kind of resources mdcat may access when rendering. @@ -28,9 +29,10 @@ pub enum ResourceAccess { RemoteAllowed, } +#[cfg(feature = "resources")] impl ResourceAccess { /// Whether the resource access permits access to the given `url`. - pub fn permits(&self, url: &Url) -> bool { + pub fn permits(self, url: &Url) -> bool { match self { ResourceAccess::LocalOnly if is_local(url) => true, ResourceAccess::RemoteAllowed => true, @@ -39,7 +41,8 @@ impl ResourceAccess { } } -pub fn is_local(url: &Url) -> bool { +#[cfg(feature = "resources")] +fn is_local(url: &Url) -> bool { url.scheme() == "file" && url.to_file_path().is_ok() } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index dabd879a..8041f671 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -18,9 +18,10 @@ mod ansi; pub mod highlighting; -mod osc; mod size; +#[cfg(feature = "osc8_links")] +mod osc; #[cfg(feature = "terminology")] mod terminology; From eae36da3cfd87a94f9c1f74bd9b7c317a85c73b0 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Tue, 30 Oct 2018 22:14:57 +0100 Subject: [PATCH 03/12] Fix clippy lints Use empty bindings instead of drop to mark variables as used --- src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 83e61189..4aed1241 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -278,8 +278,10 @@ impl<'a, W: Write> Context<'a, W> { ) -> Context<'a, W> { #[cfg(not(feature = "resources"))] { - drop(base_dir); - drop(resource_access) + // Mark variables as used if resources are disabled to keep public + // interface stable but avoid compiler warnings + let _ = base_dir; + let _ = resource_access; } Context { #[cfg(feature = "resources")] @@ -610,7 +612,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err } LinkCapability::None => { // Just mark destination as used - drop(destination); + let _ = destination; } } } @@ -633,7 +635,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err } ImageCapability::None => { // Just to mark "link" as used - drop(link); + let _ = link; } }, }; From a862e6a8c379dd714e7550919e55abd68918ad11 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 1 Nov 2018 16:51:51 +0100 Subject: [PATCH 04/12] Do not build resource tests if feature is off --- src/resources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources.rs b/src/resources.rs index 9c6bec6d..8ef0b310 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -185,7 +185,7 @@ fn is_local(url: &Url) -> bool { // }.into()) // } -#[cfg(test)] +#[cfg(all(test, feature = "resources"))] mod tests { use super::*; From 7474a79a9a1f04a5f2204a3f64976c0afd42cd90 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 1 Nov 2018 17:12:08 +0100 Subject: [PATCH 05/12] Restore marks on iterm2 --- src/lib.rs | 13 ++++++++- src/terminal/iterm2.rs | 60 +++++++++--------------------------------- src/terminal/mod.rs | 23 +++++++++++----- 3 files changed, 41 insertions(+), 55 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4aed1241..8289b537 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,7 @@ use pulldown_cmark::Tag::*; use pulldown_cmark::{Event, Tag}; use std::borrow::Cow; use std::collections::VecDeque; +use std::io; use std::io::Write; use std::path::Path; use syntect::easy::HighlightLines; @@ -480,6 +481,16 @@ impl<'a, W: Write> Context<'a, W> { } Ok(()) } + + /// Set a mark on the current position of the terminal if supported, + /// otherwise do nothing. + fn set_mark_if_supported(&mut self) -> io::Result<()> { + match self.output.capabilities.marks { + #[cfg(feature = "iterm2")] + MarkCapability::ITerm2(ref marks) => marks.set_mark(self.output.writer), + MarkCapability::None => Ok(()), + } + } } /// Write a single `event` in the given context. @@ -528,7 +539,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err // them close to the text where they appeared in ctx.write_pending_links()?; ctx.start_inline_text()?; - // ctx.output.terminal.set_mark().ignore_not_supported()?; + ctx.set_mark_if_supported()?; ctx.set_style(Style::new().fg(Colour::Blue).bold()); ctx.write_styled_current("\u{2504}".repeat(level as usize))? } diff --git a/src/terminal/iterm2.rs b/src/terminal/iterm2.rs index 299dd37d..56d0dffa 100644 --- a/src/terminal/iterm2.rs +++ b/src/terminal/iterm2.rs @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -19,14 +19,8 @@ //! //! See for more information. -use base64; -use failure::Error; -use mime; -use std; -use std::ffi::OsStr; -use std::io; -use std::io::Write; -use std::os::unix::ffi::OsStrExt; +use super::osc::write_osc; +use std::io::{Result, Write}; /// Whether we run inside iTerm2 or not. pub fn is_iterm2() -> bool { @@ -35,6 +29,15 @@ pub fn is_iterm2() -> bool { .unwrap_or(false) } +pub struct Marks; + +impl Marks { + /// Write an iterm2 mark command to the given `writer`. + pub fn set_mark(&self, writer: &mut W) -> Result<()> { + write_osc(writer, "1337;SetMark") + } +} + // impl ITerm2 { // /// Create an iTerm2 terminal over an underlying ANSI terminal. // pub fn new(ansi: AnsiTerminal) -> ITerm2 { @@ -80,42 +83,3 @@ pub fn is_iterm2() -> bool { // } // } // } - -// impl Terminal for ITerm2 { -// type TerminalWrite = W; - -// fn name(&self) -> &'static str { -// "iTerm2" -// } - -// fn write(&mut self) -> &mut W { -// self.ansi.write() -// } - -// fn supports_styles(&self) -> bool { -// self.ansi.supports_styles() -// } - -// fn set_link(&mut self, destination: &str) -> Result<(), Error> { -// self.ansi.write_osc(&format!("8;;{}", destination))?; -// Ok(()) -// } - -// fn set_mark(&mut self) -> Result<(), Error> { -// self.ansi.write_osc("1337;SetMark")?; -// Ok(()) -// } - -// fn write_inline_image( -// &mut self, -// _max_size: Size, -// resource: &Resource, -// access: ResourceAccess, -// ) -> Result<(), Error> { -// resource.read(access).and_then(|contents| { -// self.write_inline_image(resource.as_str().as_ref(), &contents) -// .map_err(Into::into) -// })?; -// Ok(()) -// } -// } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 8041f671..63d7c4e2 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -20,7 +20,9 @@ mod ansi; pub mod highlighting; mod size; -#[cfg(feature = "osc8_links")] +#[cfg(feature = "iterm2")] +mod iterm2; +#[cfg(any(feature = "osc8_links", feature = "iterm2"))] mod osc; #[cfg(feature = "terminology")] mod terminology; @@ -51,6 +53,9 @@ pub enum LinkCapability { pub enum MarkCapability { /// The terminal can't set marks. None, + /// The terminal supports iTerm2 jump marks. + #[cfg(feature = "iterm2")] + ITerm2(self::iterm2::Marks), } /// The capability of the terminal to write images inline. @@ -106,11 +111,17 @@ impl TerminalCapabilities { // why we have this weird match here. Note: Don't use true here because // that makes clippy complain. match 1 { - // #[cfg(feature = "iterm2")] - // _ if iterm2::is_iterm2() => - // { - // Box::new(ITerm2::new(ansi)) - // } + #[cfg(feature = "iterm2")] + _ if self::iterm2::is_iterm2() => + { + TerminalCapabilities { + name: "iTerm2".to_string(), + style: StyleCapability::Ansi(AnsiStyle), + links: LinkCapability::OSC8(self::osc::OSC8Links), + image: ImageCapability::None, + marks: MarkCapability::ITerm2(self::iterm2::Marks), + } + } #[cfg(feature = "terminology")] _ if self::terminology::is_terminology() => { From 144ae578cf7123e11abeb73db930025f421f60e4 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 1 Nov 2018 17:17:22 +0100 Subject: [PATCH 06/12] Don't use failure when not required --- src/lib.rs | 23 +++++++++++------------ src/terminal/highlighting.rs | 5 ++--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8289b537..2b511620 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -327,7 +327,7 @@ impl<'a, W: Write> Context<'a, W> { /// /// Set `block_context` accordingly, and separate this block from the /// previous. - fn start_inline_text(&mut self) -> Result<(), Error> { + fn start_inline_text(&mut self) -> io::Result<()> { if let BlockLevel::Block = self.block.level { self.newline_and_indent()? }; @@ -340,9 +340,9 @@ impl<'a, W: Write> Context<'a, W> { /// /// Set `block_context` accordingly and end inline context—if present—with /// a line break. - fn end_inline_text_with_margin(&mut self) -> Result<(), Error> { + fn end_inline_text_with_margin(&mut self) -> io::Result<()> { if let BlockLevel::Inline = self.block.level { - self.newline()? + self.newline()?; }; // We are back at blocks now self.block.level = BlockLevel::Block; @@ -352,22 +352,21 @@ impl<'a, W: Write> Context<'a, W> { /// Write a newline. /// /// Restart all current styles after the newline. - fn newline(&mut self) -> Result<(), Error> { - writeln!(self.output.writer)?; - Ok(()) + fn newline(&mut self) -> io::Result<()> { + writeln!(self.output.writer) } /// Write a newline and indent. /// /// Reset format before the line break, and set all active styles again /// after the line break. - fn newline_and_indent(&mut self) -> Result<(), Error> { + fn newline_and_indent(&mut self) -> io::Result<()> { self.newline()?; self.indent() } /// Indent according to the current indentation level. - fn indent(&mut self) -> Result<(), Error> { + fn indent(&mut self) -> io::Result<()> { write!( self.output.writer, "{}", @@ -393,7 +392,7 @@ impl<'a, W: Write> Context<'a, W> { } /// Write `text` with the given `style`. - fn write_styled>(&mut self, style: &Style, text: S) -> Result<(), Error> { + fn write_styled>(&mut self, style: &Style, text: S) -> io::Result<()> { match self.output.capabilities.style { StyleCapability::None => writeln!(self.output.writer, "{}", text.as_ref())?, StyleCapability::Ansi(ref ansi) => { @@ -404,7 +403,7 @@ impl<'a, W: Write> Context<'a, W> { } /// Write `text` with current style. - fn write_styled_current>(&mut self, text: S) -> Result<(), Error> { + fn write_styled_current>(&mut self, text: S) -> io::Result<()> { let style = self.style.current; self.write_styled(&style, text) } @@ -455,7 +454,7 @@ impl<'a, W: Write> Context<'a, W> { } /// Write a simple border. - fn write_border(&mut self) -> Result<(), Error> { + fn write_border(&mut self) -> io::Result<()> { let separator = "\u{2500}".repeat(self.output.size.width.min(20)); let style = self.style.current.fg(Colour::Green); self.write_styled(&style, separator)?; @@ -466,7 +465,7 @@ impl<'a, W: Write> Context<'a, W> { /// /// If the code context has a highlighter, use it to highlight `text` and /// write it. Otherwise write `text` without highlighting. - fn write_highlighted(&mut self, text: Cow<'a, str>) -> Result<(), Error> { + fn write_highlighted(&mut self, text: Cow<'a, str>) -> io::Result<()> { let mut wrote_highlighted: bool = false; if let Some(ref mut highlighter) = self.code.current_highlighter { if let StyleCapability::Ansi(ref ansi) = self.output.capabilities.style { diff --git a/src/terminal/highlighting.rs b/src/terminal/highlighting.rs index b17a03ac..142f0fb2 100644 --- a/src/terminal/highlighting.rs +++ b/src/terminal/highlighting.rs @@ -16,8 +16,7 @@ use super::ansi::AnsiStyle; use ansi_term::Colour; -use failure::Error; -use std::io::Write; +use std::io::{Result, Write}; use syntect::highlighting::{FontStyle, Style}; /// Write regions as ANSI 8-bit coloured text. @@ -39,7 +38,7 @@ pub fn write_as_ansi( writer: &mut W, ansi: &AnsiStyle, regions: &[(Style, &str)], -) -> Result<(), Error> { +) -> Result<()> { for &(style, text) in regions { let rgb = { let fg = style.foreground; From 4c8abf603723634b85e36891481621becbd75d6b Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Fri, 2 Nov 2018 12:09:50 +0100 Subject: [PATCH 07/12] Add iterm2 inline images back --- src/lib.rs | 24 +++++-- src/resources.rs | 100 ++++++++--------------------- src/terminal/iterm2.rs | 85 ------------------------ src/{ => terminal/iterm2}/magic.rs | 28 ++++---- src/terminal/iterm2/mod.rs | 87 +++++++++++++++++++++++++ src/{ => terminal/iterm2}/svg.rs | 22 ++++--- src/terminal/mod.rs | 11 ++-- src/terminal/terminology.rs | 2 +- 8 files changed, 166 insertions(+), 193 deletions(-) delete mode 100644 src/terminal/iterm2.rs rename src/{ => terminal/iterm2}/magic.rs (75%) create mode 100644 src/terminal/iterm2/mod.rs rename src/{ => terminal/iterm2}/svg.rs (73%) diff --git a/src/lib.rs b/src/lib.rs index 2b511620..7beba73c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,10 +25,10 @@ // extern crate reqwest; // Used by iTerm support on macos -// #[cfg(feature = "iterm2")] -// extern crate base64; -// #[cfg(feature = "iterm2")] -// extern crate mime; +#[cfg(feature = "iterm2")] +extern crate base64; +#[cfg(feature = "iterm2")] +extern crate mime; // Used by Terminology support #[cfg(feature = "terminology")] @@ -628,7 +628,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err } Image(link, _title) => match ctx.output.capabilities.image { #[cfg(feature = "terminology")] - ImageCapability::Terminology(ref mut terminology) => { + ImageCapability::Terminology(ref terminology) => { let access = ctx.resources.resource_access; if let Some(url) = ctx .resources @@ -643,6 +643,20 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err ctx.image.inline_image = true; } } + #[cfg(feature = "iterm2")] + ImageCapability::ITerm2(ref iterm2) => { + let access = ctx.resources.resource_access; + if let Some(url) = ctx + .resources + .resolve_reference(&link) + .filter(|url| access.permits(url)) + { + if let Ok(contents) = iterm2.read_and_render(&url) { + iterm2.write_inline_image(ctx.output.writer, url.as_str(), &contents)?; + ctx.image.inline_image = true; + } + } + } ImageCapability::None => { // Just to mark "link" as used let _ = link; diff --git a/src/resources.rs b/src/resources.rs index 8ef0b310..8a493562 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -41,11 +41,37 @@ impl ResourceAccess { } } +/// Whether `url` is readable as local file:. #[cfg(feature = "resources")] fn is_local(url: &Url) -> bool { url.scheme() == "file" && url.to_file_path().is_ok() } +#[cfg(feature = "resources")] +pub fn read_url(url: &Url) -> std::io::Result> { + use std::fs::File; + use std::io::prelude::*; + use std::io::{Error, ErrorKind}; + + match url.scheme() { + "file" => match url.to_file_path() { + Ok(path) => { + let mut buffer = Vec::new(); + File::open(path)?.read_to_end(&mut buffer)?; + Ok(buffer) + } + Err(_) => Err(Error::new( + ErrorKind::InvalidInput, + format!("Remote file: URL {} not supported", url), + )), + }, + _ => Err(Error::new( + ErrorKind::InvalidInput, + format!("Protocol of URL {} not supported", url), + )), + } +} + // /// A non-200 status code from a HTTP request. // #[derive(Debug, Fail)] // #[fail( @@ -62,80 +88,6 @@ fn is_local(url: &Url) -> bool { // } // impl<'a> Resource<'a> { -// /// Obtain a resource from a markdown `reference`. -// /// -// /// Try to parse `reference` as a URL. If this succeeds assume that -// /// `reference` refers to a remote resource and return a `Remote` resource. -// /// -// /// Otherwise assume that `reference` denotes a local file by its path and -// /// return a `LocalFile` resource. If `reference` holds a relative path -// /// join it against `base_dir` first. -// pub fn from_reference(base_dir: &Path, reference: &'a str) -> Resource<'a> { -// if let Ok(url) = Url::parse(reference) { -// Resource::Remote(url) -// } else { -// let path = Path::new(reference); -// if path.is_absolute() { -// Resource::LocalFile(Cow::Borrowed(path)) -// } else { -// Resource::LocalFile(Cow::Owned(base_dir.join(path))) -// } -// } -// } - -// /// Whether this resource is local. -// fn is_local(&self) -> bool { -// match *self { -// Resource::LocalFile(_) => true, -// _ => false, -// } -// } - -// /// Whether we may access this resource under the given access permissions. -// pub fn may_access(&self, access: ResourceAccess) -> bool { -// match access { -// ResourceAccess::RemoteAllowed => true, -// ResourceAccess::LocalOnly => self.is_local(), -// } -// } - -// /// Convert this resource into a URL. -// /// -// /// Return a `Remote` resource as is, and a `LocalFile` as `file:` URL. -// pub fn into_url(self) -> Url { -// match self { -// Resource::Remote(url) => url, -// Resource::LocalFile(path) => Url::parse("file:///") -// .expect("Failed to parse file root URL!") -// .join(&path.to_string_lossy()) -// .unwrap_or_else(|_| panic!(format!("Failed to join root URL with {:?}", path))), -// } -// } - -// /// Extract the local path from this resource. -// /// -// /// If the resource is a `LocalFile`, or a `file://` URL pointing to a local -// /// file return the local path, otherwise return `None`. -// pub fn local_path(&'a self) -> Option> { -// match *self { -// Resource::Remote(ref url) if url.scheme() == "file" && url.host().is_none() => { -// Some(Cow::Borrowed(Path::new(url.path()))) -// } -// Resource::LocalFile(ref path) => Some(Cow::Borrowed(path)), -// _ => None, -// } -// } - -// /// Convert this resource to a string. -// /// -// /// For local resource return the lossy UTF-8 representation of the path, -// /// for remote resource the string serialization of the URL. -// pub fn as_str(&'a self) -> Cow<'a, str> { -// match *self { -// Resource::Remote(ref url) => Cow::Borrowed(url.as_str()), -// Resource::LocalFile(ref path) => path.to_string_lossy(), -// } -// } // /// Read the contents of this resource. // /// diff --git a/src/terminal/iterm2.rs b/src/terminal/iterm2.rs deleted file mode 100644 index 56d0dffa..00000000 --- a/src/terminal/iterm2.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! The iTerm2 terminal. -//! -//! iTerm2 is a powerful macOS terminal emulator with many formatting -//! features, including images and inline links. -//! -//! See for more information. - -use super::osc::write_osc; -use std::io::{Result, Write}; - -/// Whether we run inside iTerm2 or not. -pub fn is_iterm2() -> bool { - std::env::var("TERM_PROGRAM") - .map(|value| value.contains("iTerm.app")) - .unwrap_or(false) -} - -pub struct Marks; - -impl Marks { - /// Write an iterm2 mark command to the given `writer`. - pub fn set_mark(&self, writer: &mut W) -> Result<()> { - write_osc(writer, "1337;SetMark") - } -} - -// impl ITerm2 { -// /// Create an iTerm2 terminal over an underlying ANSI terminal. -// pub fn new(ansi: AnsiTerminal) -> ITerm2 { -// ITerm2 { ansi } -// } - -// fn write_image_contents>( -// &mut self, -// name: S, -// contents: &[u8], -// ) -> io::Result<()> { -// self.ansi.write_osc(&format!( -// "1337;File=name={};inline=1:{}", -// base64::encode(name.as_ref().as_bytes()), -// base64::encode(contents) -// )) -// } - -// /// Write an iterm2 inline image. -// /// -// /// `name` is the file name of the image, and `contents` holds the image -// /// contents. -// pub fn write_inline_image>( -// &mut self, -// name: S, -// contents: &[u8], -// ) -> Result<(), Error> { -// let mime = magic::detect_mime_type(contents)?; -// match (mime.type_(), mime.subtype()) { -// (mime::IMAGE, mime::PNG) -// | (mime::IMAGE, mime::GIF) -// | (mime::IMAGE, mime::JPEG) -// | (mime::IMAGE, mime::BMP) => self -// .write_image_contents(name, contents) -// .map_err(Into::into), -// (mime::IMAGE, subtype) if subtype.as_str() == "svg" => { -// let png = svg::render_svg(contents)?; -// self.write_image_contents(name, &png).map_err(Into::into) -// } -// _ => Err(NotSupportedError { -// what: "inline image with mimetype", -// }.into()), -// } -// } -// } diff --git a/src/magic.rs b/src/terminal/iterm2/magic.rs similarity index 75% rename from src/magic.rs rename to src/terminal/iterm2/magic.rs index 0279c835..00e8f28c 100644 --- a/src/magic.rs +++ b/src/terminal/iterm2/magic.rs @@ -14,15 +14,12 @@ //! Detect mime type with `file`. -use failure::Error; -// use mime::Mime; +use mime::Mime; use std::io::prelude::*; +use std::io::{Error, ErrorKind}; use std::process::*; -use std::str; -use process::ProcessError; - -pub fn detect_mime_type(buffer: &[u8]) -> Result { +pub fn detect_mime_type(buffer: &[u8]) -> Result { let mut process = Command::new("file") .arg("--brief") .arg("--mime-type") @@ -40,16 +37,19 @@ pub fn detect_mime_type(buffer: &[u8]) -> Result { let output = process.wait_with_output()?; if output.status.success() { - str::from_utf8(&output.stdout)? + std::str::from_utf8(&output.stdout)? .trim() .parse() .map_err(Into::into) } else { - Err(ProcessError { - command: "file --brief --mime-type".to_string(), - status: output.status, - error: String::from_utf8_lossy(&output.stderr).into_owned(), - }.into()) + Err(Error::new( + ErrorKind::Other, + format!( + "file --brief --mime-type failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ), + ).into()) } } @@ -59,7 +59,7 @@ mod tests { #[test] fn detect_mimetype_of_png_image() { - let data = include_bytes!("../sample/rust-logo-128x128.png"); + let data = include_bytes!("../../../sample/rust-logo-128x128.png"); let result = detect_mime_type(data); assert!(result.is_ok(), "Unexpected error: {:?}", result); assert_eq!(result.unwrap(), mime::IMAGE_PNG); @@ -67,7 +67,7 @@ mod tests { #[test] fn detect_mimetype_of_svg_image() { - let data = include_bytes!("../sample/rust-logo.svg"); + let data = include_bytes!("../../../sample/rust-logo.svg"); let result = detect_mime_type(data); assert!(result.is_ok(), "Unexpected error: {:?}", result); let mime = result.unwrap(); diff --git a/src/terminal/iterm2/mod.rs b/src/terminal/iterm2/mod.rs new file mode 100644 index 00000000..10196131 --- /dev/null +++ b/src/terminal/iterm2/mod.rs @@ -0,0 +1,87 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The iTerm2 terminal. +//! +//! iTerm2 is a powerful macOS terminal emulator with many formatting +//! features, including images and inline links. +//! +//! See for more information. + +use super::osc::write_osc; +use failure::Error; +use std::ffi::OsStr; +use std::io::{Result, Write}; +use std::os::unix::ffi::OsStrExt; +use url::Url; + +mod magic; + +pub mod svg; + +/// Whether we run inside iTerm2 or not. +pub fn is_iterm2() -> bool { + std::env::var("TERM_PROGRAM") + .map(|value| value.contains("iTerm.app")) + .unwrap_or(false) +} + +/// Iterm2 marks. +pub struct ITerm2Marks; + +impl ITerm2Marks { + /// Write an iterm2 mark command to the given `writer`. + pub fn set_mark(&self, writer: &mut W) -> Result<()> { + write_osc(writer, "1337;SetMark") + } +} + +/// Iterm2 inline iamges. +pub struct ITerm2Images; + +impl ITerm2Images { + /// Write an iterm2 inline image command to `writer`. + /// + /// `name` is the local file name and `contents` are the contents of the + /// given file. + pub fn write_inline_image>( + &self, + writer: &mut W, + name: S, + contents: &[u8], + ) -> Result<()> { + write_osc( + writer, + &format!( + "1337;File=name={};inline=1:{}", + base64::encode(name.as_ref().as_bytes()), + base64::encode(contents) + ), + ) + } + + /// Read `url` and render to an image if necessary. + /// + /// Render the binary content of the (rendered) image or an IO error if + /// reading or rendering failed. + pub fn read_and_render(&self, url: &Url) -> std::result::Result, Error> { + let contents = crate::resources::read_url(&url)?; + let mime = magic::detect_mime_type(&contents)?; + if mime.type_() == mime::IMAGE && mime.subtype().as_str() == "svg" { + svg::render_svg(&contents).map_err(Into::into) + } else { + Ok(contents) + } + } +} diff --git a/src/svg.rs b/src/terminal/iterm2/svg.rs similarity index 73% rename from src/svg.rs rename to src/terminal/iterm2/svg.rs index 0a793101..fcc9dfd8 100644 --- a/src/svg.rs +++ b/src/terminal/iterm2/svg.rs @@ -14,18 +14,17 @@ //! SVG "rendering" for mdcat. -use failure::Error; -use process::ProcessError; use std::io::prelude::*; +use std::io::{Error, ErrorKind, Result}; use std::process::{Command, Stdio}; /// Render an SVG image to a PNG pixel graphic for display. -pub fn render_svg(svg: &[u8]) -> Result, Error> { +pub fn render_svg(svg: &[u8]) -> Result> { render_svg_with_rsvg_convert(svg) } -/// Render an SVG file with `rsvg-convert -fn render_svg_with_rsvg_convert(svg: &[u8]) -> Result, Error> { +/// Render an SVG file with `rsvg-convert`. +fn render_svg_with_rsvg_convert(svg: &[u8]) -> Result> { let mut process = Command::new("rsvg-convert") .arg("--dpi-x=72") .arg("--dpi-y=72") @@ -45,10 +44,13 @@ fn render_svg_with_rsvg_convert(svg: &[u8]) -> Result, Error> { if output.status.success() { Ok(output.stdout) } else { - Err(ProcessError { - command: "file --brief --mime-type".to_string(), - status: output.status, - error: String::from_utf8_lossy(&output.stderr).into_owned(), - }.into()) + Err(Error::new( + ErrorKind::Other, + format!( + "rsvg-convert failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ), + )) } } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 63d7c4e2..d19279e0 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -55,7 +55,7 @@ pub enum MarkCapability { None, /// The terminal supports iTerm2 jump marks. #[cfg(feature = "iterm2")] - ITerm2(self::iterm2::Marks), + ITerm2(self::iterm2::ITerm2Marks), } /// The capability of the terminal to write images inline. @@ -64,7 +64,10 @@ pub enum ImageCapability { None, /// The terminal understands the terminology way of inline images. #[cfg(feature = "terminology")] - Terminology(terminology::TerminologyImages), + Terminology(self::terminology::TerminologyImages), + /// The terminal understands the iterm2 way of inline images. + #[cfg(feature = "iterm2")] + ITerm2(self::iterm2::ITerm2Images), } /// The capabilities of a terminal. @@ -118,8 +121,8 @@ impl TerminalCapabilities { name: "iTerm2".to_string(), style: StyleCapability::Ansi(AnsiStyle), links: LinkCapability::OSC8(self::osc::OSC8Links), - image: ImageCapability::None, - marks: MarkCapability::ITerm2(self::iterm2::Marks), + image: ImageCapability::ITerm2(self::iterm2::ITerm2Images), + marks: MarkCapability::ITerm2(self::iterm2::ITerm2Marks), } } #[cfg(feature = "terminology")] diff --git a/src/terminal/terminology.rs b/src/terminal/terminology.rs index 193cddf6..58cf8307 100644 --- a/src/terminal/terminology.rs +++ b/src/terminal/terminology.rs @@ -33,7 +33,7 @@ pub struct TerminologyImages; impl TerminologyImages { /// Write an inline image for Terminology. pub fn write_inline_image( - &mut self, + &self, writer: &mut W, max_size: TerminalSize, url: &Url, From b256eda943f697a7994223c1a1d7e921ac678712 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Fri, 2 Nov 2018 13:09:34 +0100 Subject: [PATCH 08/12] Add remote resoures back --- Cargo.toml | 3 +- src/lib.rs | 32 ++++---- src/resources.rs | 197 +++++++++++++---------------------------------- 3 files changed, 73 insertions(+), 159 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f7f9ad62..837d46a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,10 @@ authors = ["Sebastian Wiesner "] travis-ci = { repository = "lunaryorn/mdcat" } [features] -default = ["vte50", "iterm2", "terminology"] +default = ["vte50", "iterm2", "terminology", "remote_resources"] resources = ["url"] +remote_resources = ["reqwest", "resources"] # Special terminal features osc8_links = ["resources"] diff --git a/src/lib.rs b/src/lib.rs index 7beba73c..8554d69c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,11 +20,19 @@ //! Write markdown to TTYs. +extern crate ansi_term; +extern crate failure; +extern crate pulldown_cmark; +extern crate syntect; +extern crate term_size; + +#[cfg(feature = "resources")] +extern crate url; // Used by remote_resources to actually fetch remote resources over HTTP -// #[cfg(feature = "remote_resources")] -// extern crate reqwest; +#[cfg(feature = "remote_resources")] +extern crate reqwest; -// Used by iTerm support on macos +// Used by iTerm support #[cfg(feature = "iterm2")] extern crate base64; #[cfg(feature = "iterm2")] @@ -34,14 +42,10 @@ extern crate mime; #[cfg(feature = "terminology")] extern crate immeta; -#[cfg(feature = "resources")] -extern crate url; - -extern crate ansi_term; -extern crate failure; -extern crate pulldown_cmark; -extern crate syntect; -extern crate term_size; +// Pretty assertions for unit tests. +#[cfg(test)] +#[macro_use] +extern crate pretty_assertions; use ansi_term::{Colour, Style}; use failure::Error; @@ -57,9 +61,6 @@ use syntect::easy::HighlightLines; use syntect::highlighting::{Theme, ThemeSet}; use syntect::parsing::SyntaxSet; -#[cfg(feature = "resources")] -use url::Url; - mod resources; mod terminal; @@ -162,7 +163,8 @@ impl<'a> ResourceContext<'a> { /// If `reference` parses as URL return the parsed URL. Otherwise assume /// `reference` is a file path, resolve it against `base_dir` and turn it /// into a file:// URL. If this also fails return `None`. - fn resolve_reference(&self, reference: &'a str) -> Option { + fn resolve_reference(&self, reference: &'a str) -> Option { + use url::Url; Url::parse(reference) .or_else(|_| Url::from_file_path(self.base_dir.join(reference))) .ok() diff --git a/src/resources.rs b/src/resources.rs index 8a493562..c88d5a20 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -47,8 +47,16 @@ fn is_local(url: &Url) -> bool { url.scheme() == "file" && url.to_file_path().is_ok() } +/// Read the contents of the given `url` if supported. +/// +/// Fail if we don’t know how to read from `url`, or if we fail to read from +/// URL. +/// +/// We currently support `file:` URLs which the underlying operation system can +/// read (local on UNIX, UNC paths on Windows), and HTTP(S) URLs if enabled at +/// build system. #[cfg(feature = "resources")] -pub fn read_url(url: &Url) -> std::io::Result> { +pub fn read_url(url: &Url) -> Result, failure::Error> { use std::fs::File; use std::io::prelude::*; use std::io::{Error, ErrorKind}; @@ -63,83 +71,32 @@ pub fn read_url(url: &Url) -> std::io::Result> { Err(_) => Err(Error::new( ErrorKind::InvalidInput, format!("Remote file: URL {} not supported", url), - )), + ).into()), }, + #[cfg(feature = "remote_resources")] + "http" | "https" => { + let mut response = reqwest::get(url.clone())?; + if response.status().is_success() { + let mut buffer = Vec::new(); + response.read_to_end(&mut buffer)?; + Ok(buffer) + } else { + Err(Error::new( + ErrorKind::Other, + format!("HTTP error status {} by GET {}", response.status(), url), + ).into()) + } + } _ => Err(Error::new( ErrorKind::InvalidInput, format!("Protocol of URL {} not supported", url), - )), + ).into()), } } -// /// A non-200 status code from a HTTP request. -// #[derive(Debug, Fail)] -// #[fail( -// display = "Url {} failed with status code {}", -// url, -// status_code -// )] -// #[cfg(feature = "remote_resources")] -// pub struct HttpStatusError { -// /// The URL that was requested -// url: Url, -// /// The status code. -// status_code: reqwest::StatusCode, -// } - -// impl<'a> Resource<'a> { - -// /// Read the contents of this resource. -// /// -// /// Supports local files and HTTP(S) resources. `access` denotes the access -// /// permissions. -// pub fn read(&self, access: ResourceAccess) -> Result, Error> { -// if self.may_access(access) { -// match *self { -// Resource::Remote(ref url) => read_http(url), -// Resource::LocalFile(ref path) => { -// let mut buffer = Vec::new(); -// File::open(path)?.read_to_end(&mut buffer)?; -// Ok(buffer) -// } -// } -// } else { -// Err(io::Error::new( -// io::ErrorKind::PermissionDenied, -// "Remote resources not allowed", -// ).into()) -// } -// } -// } - -// /// Read a resource from HTTP(S). -// #[cfg(feature = "remote_resources")] -// fn read_http(url: &Url) -> Result, Error> { -// // We need to clone "Url" here because for some reason `get` -// // claims ownership of Url which we don't have here. -// let mut response = reqwest::get(url.clone())?; -// if response.status().is_success() { -// let mut buffer = Vec::new(); -// response.read_to_end(&mut buffer)?; -// Ok(buffer) -// } else { -// Err(HttpStatusError { -// url: url.clone(), -// status_code: response.status(), -// }.into()) -// } -// } - -// #[cfg(not(feature = "remote_resources"))] -// fn read_http(_url: &Url) -> Result, Error> { -// Err(NotSupportedError { -// what: "remote resources", -// }.into()) -// } - #[cfg(all(test, feature = "resources"))] mod tests { - use super::*; + pub use super::*; #[test] fn resource_access_permits_local_resource() { @@ -162,78 +119,32 @@ mod tests { assert!(ResourceAccess::RemoteAllowed.permits(&resource)); } - // mod read { - // use super::*; - // use std::error::Error; - - // #[test] - // fn remote_resource_fails_with_permission_denied_without_access() { - // let resource = Resource::Remote( - // "https://eu.httpbin.org/bytes/100" - // .parse() - // .expect("No valid URL"), - // ); - // let result = resource.read(ResourceAccess::LocalOnly); - // assert!(result.is_err(), "Unexpected success: {:?}", result); - // let error = match result.unwrap_err().downcast::() { - // Ok(e) => e, - // Err(error) => panic!("Not an IO error: {:?}", error), - // }; - - // assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); - // assert_eq!(error.description(), "Remote resources not allowed"); - // } - - // #[cfg(feature = "remote_resources")] - // #[test] - // fn remote_resource_fails_when_status_404() { - // let url: Url = "https://eu.httpbin.org/status/404" - // .parse() - // .expect("No valid URL"); - // let resource = Resource::Remote(url.clone()); - // let result = resource.read(ResourceAccess::RemoteAllowed); - // assert!(result.is_err(), "Unexpected success: {:?}", result); - // let error = result - // .unwrap_err() - // .downcast::() - // .expect("Not an IO error"); - // assert_eq!(error.status_code, reqwest::StatusCode::NOT_FOUND); - // assert_eq!(error.url, url); - // } - - // #[cfg(feature = "remote_resources")] - // #[test] - // fn remote_resource_returns_content_when_status_200() { - // let resource = Resource::Remote( - // "https://eu.httpbin.org/bytes/100" - // .parse() - // .expect("No valid URL"), - // ); - // let result = resource.read(ResourceAccess::RemoteAllowed); - // assert!(result.is_ok(), "Unexpected error: {:?}", result); - // assert_eq!(result.unwrap().len(), 100); - // } - - // #[cfg(not(feature = "remote_resources"))] - // #[test] - // fn remote_resource_returns_not_supported_if_feature_is_disabled() { - // let resource = Resource::Remote( - // "https://eu.httpbin.org/bytes/100" - // .parse() - // .expect("No valid URL"), - // ); - // let result = resource.read(ResourceAccess::RemoteAllowed); - // assert!(result.is_err(), "Unexpected success: {:?}", result); - // let error = result - // .unwrap_err() - // .downcast::() - // .expect("Not a NotSupportedError!"); - // assert_eq!( - // error, - // NotSupportedError { - // what: "remote resources" - // } - // ); - // } - // } + #[cfg(all(test, feature = "remote_resources"))] + mod remote { + use super::*; + + #[test] + fn read_url_with_http_url_fails_when_status_404() { + let url = "https://eu.httpbin.org/status/404" + .parse::() + .unwrap(); + let result = read_url(&url); + assert!(result.is_err(), "Unexpected success: {:?}", result); + let error = result.unwrap_err().to_string(); + assert_eq!( + error, + "HTTP error status 404 Not Found by GET https://eu.httpbin.org/status/404" + ) + } + + #[test] + fn read_url_with_http_url_returns_content_when_status_200() { + let url = "https://eu.httpbin.org/bytes/100" + .parse::() + .unwrap(); + let result = read_url(&url); + assert!(result.is_ok(), "Unexpected error: {:?}", result); + assert_eq!(result.unwrap().len(), 100); + } + } } From 4f5c0313570e76760a60f112119041ca21abb2b1 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sat, 3 Nov 2018 10:20:02 +0100 Subject: [PATCH 09/12] Remove unused file --- src/process.rs | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/process.rs diff --git a/src/process.rs b/src/process.rs deleted file mode 100644 index 1ae02388..00000000 --- a/src/process.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2018 Sebastian Wiesner - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Tools for subprocesses. - -use std::process::ExitStatus; - -/// A process failed. -#[derive(Debug, Fail)] -#[fail( - display = "Command {} failed with {}: {}", - command, - status, - error -)] -pub struct ProcessError { - /// The command that failed. - pub command: String, - /// The exit code of the failed command. - pub status: ExitStatus, - /// The error output of the command. - pub error: String, -} From 523e9176889d746ce90670fb3b35133d24653a04 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 4 Nov 2018 19:41:33 +0100 Subject: [PATCH 10/12] Ignore unused warning for pretty_assertions --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 8554d69c..851fed46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ extern crate immeta; // Pretty assertions for unit tests. #[cfg(test)] #[macro_use] +#[allow(unused_imports)] extern crate pretty_assertions; use ansi_term::{Colour, Style}; From 7eb2c71c29b3b9ede6a3ae57d41e16b490a84917 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 4 Nov 2018 19:51:52 +0100 Subject: [PATCH 11/12] Move read_url to iterm2 module Only iterm2 actually uses it. Fixes unused warning when compiling only with terminology. --- src/resources.rs | 76 ---------------------------------- src/terminal/iterm2/mod.rs | 85 +++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 81 deletions(-) diff --git a/src/resources.rs b/src/resources.rs index c88d5a20..7d646c36 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -47,53 +47,6 @@ fn is_local(url: &Url) -> bool { url.scheme() == "file" && url.to_file_path().is_ok() } -/// Read the contents of the given `url` if supported. -/// -/// Fail if we don’t know how to read from `url`, or if we fail to read from -/// URL. -/// -/// We currently support `file:` URLs which the underlying operation system can -/// read (local on UNIX, UNC paths on Windows), and HTTP(S) URLs if enabled at -/// build system. -#[cfg(feature = "resources")] -pub fn read_url(url: &Url) -> Result, failure::Error> { - use std::fs::File; - use std::io::prelude::*; - use std::io::{Error, ErrorKind}; - - match url.scheme() { - "file" => match url.to_file_path() { - Ok(path) => { - let mut buffer = Vec::new(); - File::open(path)?.read_to_end(&mut buffer)?; - Ok(buffer) - } - Err(_) => Err(Error::new( - ErrorKind::InvalidInput, - format!("Remote file: URL {} not supported", url), - ).into()), - }, - #[cfg(feature = "remote_resources")] - "http" | "https" => { - let mut response = reqwest::get(url.clone())?; - if response.status().is_success() { - let mut buffer = Vec::new(); - response.read_to_end(&mut buffer)?; - Ok(buffer) - } else { - Err(Error::new( - ErrorKind::Other, - format!("HTTP error status {} by GET {}", response.status(), url), - ).into()) - } - } - _ => Err(Error::new( - ErrorKind::InvalidInput, - format!("Protocol of URL {} not supported", url), - ).into()), - } -} - #[cfg(all(test, feature = "resources"))] mod tests { pub use super::*; @@ -118,33 +71,4 @@ mod tests { assert!(!ResourceAccess::LocalOnly.permits(&resource)); assert!(ResourceAccess::RemoteAllowed.permits(&resource)); } - - #[cfg(all(test, feature = "remote_resources"))] - mod remote { - use super::*; - - #[test] - fn read_url_with_http_url_fails_when_status_404() { - let url = "https://eu.httpbin.org/status/404" - .parse::() - .unwrap(); - let result = read_url(&url); - assert!(result.is_err(), "Unexpected success: {:?}", result); - let error = result.unwrap_err().to_string(); - assert_eq!( - error, - "HTTP error status 404 Not Found by GET https://eu.httpbin.org/status/404" - ) - } - - #[test] - fn read_url_with_http_url_returns_content_when_status_200() { - let url = "https://eu.httpbin.org/bytes/100" - .parse::() - .unwrap(); - let result = read_url(&url); - assert!(result.is_ok(), "Unexpected error: {:?}", result); - assert_eq!(result.unwrap().len(), 100); - } - } } diff --git a/src/terminal/iterm2/mod.rs b/src/terminal/iterm2/mod.rs index 10196131..d474020a 100644 --- a/src/terminal/iterm2/mod.rs +++ b/src/terminal/iterm2/mod.rs @@ -22,7 +22,7 @@ use super::osc::write_osc; use failure::Error; use std::ffi::OsStr; -use std::io::{Result, Write}; +use std::io::{self, Write}; use std::os::unix::ffi::OsStrExt; use url::Url; @@ -42,7 +42,7 @@ pub struct ITerm2Marks; impl ITerm2Marks { /// Write an iterm2 mark command to the given `writer`. - pub fn set_mark(&self, writer: &mut W) -> Result<()> { + pub fn set_mark(&self, writer: &mut W) -> io::Result<()> { write_osc(writer, "1337;SetMark") } } @@ -50,6 +50,52 @@ impl ITerm2Marks { /// Iterm2 inline iamges. pub struct ITerm2Images; +/// Read the contents of the given `url` if supported. +/// +/// Fail if we don’t know how to read from `url`, or if we fail to read from +/// URL. +/// +/// We currently support `file:` URLs which the underlying operation system can +/// read (local on UNIX, UNC paths on Windows), and HTTP(S) URLs if enabled at +/// build system. +fn read_url(url: &Url) -> Result, Error> { + use std::fs::File; + use std::io::prelude::*; + use std::io::{Error, ErrorKind}; + + match url.scheme() { + "file" => match url.to_file_path() { + Ok(path) => { + let mut buffer = Vec::new(); + File::open(path)?.read_to_end(&mut buffer)?; + Ok(buffer) + } + Err(_) => Err(Error::new( + ErrorKind::InvalidInput, + format!("Remote file: URL {} not supported", url), + ).into()), + }, + #[cfg(feature = "remote_resources")] + "http" | "https" => { + let mut response = reqwest::get(url.clone())?; + if response.status().is_success() { + let mut buffer = Vec::new(); + response.read_to_end(&mut buffer)?; + Ok(buffer) + } else { + Err(Error::new( + ErrorKind::Other, + format!("HTTP error status {} by GET {}", response.status(), url), + ).into()) + } + } + _ => Err(Error::new( + ErrorKind::InvalidInput, + format!("Protocol of URL {} not supported", url), + ).into()), + } +} + impl ITerm2Images { /// Write an iterm2 inline image command to `writer`. /// @@ -60,7 +106,7 @@ impl ITerm2Images { writer: &mut W, name: S, contents: &[u8], - ) -> Result<()> { + ) -> io::Result<()> { write_osc( writer, &format!( @@ -75,8 +121,8 @@ impl ITerm2Images { /// /// Render the binary content of the (rendered) image or an IO error if /// reading or rendering failed. - pub fn read_and_render(&self, url: &Url) -> std::result::Result, Error> { - let contents = crate::resources::read_url(&url)?; + pub fn read_and_render(&self, url: &Url) -> Result, Error> { + let contents = read_url(&url)?; let mime = magic::detect_mime_type(&contents)?; if mime.type_() == mime::IMAGE && mime.subtype().as_str() == "svg" { svg::render_svg(&contents).map_err(Into::into) @@ -85,3 +131,32 @@ impl ITerm2Images { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_url_with_http_url_fails_when_status_404() { + let url = "https://eu.httpbin.org/status/404" + .parse::() + .unwrap(); + let result = read_url(&url); + assert!(result.is_err(), "Unexpected success: {:?}", result); + let error = result.unwrap_err().to_string(); + assert_eq!( + error, + "HTTP error status 404 Not Found by GET https://eu.httpbin.org/status/404" + ) + } + + #[test] + fn read_url_with_http_url_returns_content_when_status_200() { + let url = "https://eu.httpbin.org/bytes/100" + .parse::() + .unwrap(); + let result = read_url(&url); + assert!(result.is_ok(), "Unexpected error: {:?}", result); + assert_eq!(result.unwrap().len(), 100); + } +} From 9fcfc962a8584e089e27b18c8bc9d1891267ff74 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 8 Nov 2018 20:11:04 +0100 Subject: [PATCH 12/12] Update changelog [ci skip] --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0759ee6..3385ecc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add `TerminalCapability` struct as replacement for `mdcat::Terminal` trait to + remove dynamic dispatch and allow for more accurate and less complicated + conditional compilation of terminal support for different platforms (see + [GH-45]). + ### Changed - Drop support for Rust 1.29 and older. - Do not test specific Rust on versions on Travis CI any longer; Rust stable becomes the lowest supported Rust version. +### Removed +- `mdcat::Terminal` trait and implementations (see [GH-45]). + +[GH-45]: https://github.com/lunaryorn/mdcat/pull/45 + ## [0.11.0] – 2018-10-25 ### Changed - Always print colours regardless of whether stdout if a tty or not.