RustのOSSを探索する

Aloxaf/silicon

Rustに対する理解がまだ浅いので、RustのOSSのソースコードを探索していろいろテクニックを盗むことにしました。

初回なので小さいOSSから始めます

RustのOSSを見つけるのに MOONGIFTさんのサイトがとても参考になりました。ありがとうございます。

今回はsiliconというOSSを選びました。このリポジトリはソースコードを綺麗に画像に出力するCLIのツールです。OSSかつ MIT Licenceなのでコードを紹介しつつお勉強しようと思います。

以下、ライセンス情報です。

MIT License

Copyright (c) 2019-2020 Aloxaf

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

全体構成

僕が自分でRustプロジェクト作るときのように、 /src/lib.rs があって安心感がありました。ですがよく見るとbin.rsもあって混乱してしまいました…両方あるとはどういうこと?

と思って Cargo.tomlを見ると、siliconをライブラリとして利用する際は lib.rsがエントリーポイントで、siliconをCLIとして利用する際は、bin.rsを使うみたいですね。

[lib]
name = "silicon"
path = "src/lib.rs"

[[bin]]
name = "silicon"
path = "src/bin.rs"

また、Cargo.tomlをもう少し見ると [features]というセクションがありました。bin = と記載があることからコンパイル時の状況によって依存関係を変化させたい場合に利用するみたいです。詳しくはこちらに書いてました。

[features]
default = ["bin"]
bin = ["structopt", "env_logger", "failure"]

src配下には8ファイル。メインの処理をまずはみてみます

  • bin.rs
  • blur.rs
  • config.rs
  • error.rs
  • font.rs
  • formatter.rs
  • lib.rs
  • utils.rs

メインの処理について

bin.rsから一部抜粋

fn main() {
    env_logger::init();

    if let Err(e) = run() {
        eprintln!("[error] {}", e);
    }
}

fn run() -> Result<(), Error> {
    let config: Config = Config::from_args();

    let (ps, ts) = init_syntect();

    if config.list_themes {
        for i in ts.themes.keys() {
            println!("{}", i);
        }
        return Ok(());
    }

    let (syntax, code) = config.get_source_code(&ps)?;

    let theme = config.theme(&ts)?;

    let mut h = HighlightLines::new(syntax, &theme);
    let highlight = LinesWithEndings::from(&code)
        .map(|line| h.highlight(line, &ps))
        .collect::<Vec<_>>();

    let mut formatter = config.get_formatter()?;

    let image = formatter.format(&highlight, &theme);

    if config.to_clipboard {
        dump_image_to_clipboard(&image)?;
    } else {
        let path = config.get_expanded_output().unwrap();
        image
            .save(&path)
            .map_err(|e| format_err!("Failed to save image to {}: {}", path.display(), e))?;
    }

    Ok(())
}

env_logger::init();

env_loggerは、環境変数RUST_LOGの値によって出力範囲を切り替えれるロガーのcrateでした。よく見る処理ですが実はちゃんと調べたことなかったです。これを知ったので println!("{:?}", x) はもうやめます

let config: Config = Config::from_args();

run関数の冒頭で設定値をconfig.rsから取得していました。のでconfig.rsをみてみましょう

#[derive(StructOpt, Debug)]
#[structopt(name = "silicon")]
pub struct Config {
    /// Background color of the image
    #[structopt(
        long,
        short,
        value_name = "COLOR",
        default_value = "#aaaaff",
        parse(try_from_str = parse_str_color)
    )]
    pub background: Rgba<u8>,

    /// Read input from clipboard.
    #[structopt(long)]
    pub from_clipboard: bool,

    /// File to read. If not set, stdin will be use.
    #[structopt(value_name = "FILE", parse(from_os_str))]
    pub file: Option<PathBuf>,

    /// The font list. eg. 'Hack; SimSun=31'
    #[structopt(long, short, value_name = "FONT", parse(from_str = parse_font_str))]
    pub font: Option<FontList>,

    /// Lines to high light. rg. '1-3; 4'
    #[structopt(long, value_name = "LINES", parse(try_from_str = parse_line_range))]
    pub highlight_lines: Option<Lines>,

    
    // etc....
}

impl Config {
    pub fn get_source_code<'a>(
        &self,
        ps: &'a SyntaxSet,
    ) -> Result<(&'a SyntaxReference, String), Error> {
        let possible_language = self.language.as_ref().map(|language| {
            ps.find_syntax_by_token(language)
                .ok_or_else(|| format_err!("Unsupported language: {}", language))
        });

        if self.from_clipboard {
            let mut ctx = ClipboardContext::new()
                .map_err(|e| format_err!("failed to access clipboard: {}", e))?;
            let code = ctx
                .get_contents()
                .map_err(|e| format_err!("failed to access clipboard: {}", e))?;

            let language = possible_language.unwrap_or_else(|| {
                ps.find_syntax_by_first_line(&code)
                    .ok_or_else(|| format_err!("Failed to detect the language"))
            })?;

            return Ok((language, code));
        }

        if let Some(path) = &self.file {
            let mut s = String::new();
            let mut file = File::open(path)?;
            file.read_to_string(&mut s)?;

            let language = possible_language.unwrap_or_else(|| {
                ps.find_syntax_for_file(path)?
                    .ok_or_else(|| format_err!("Failed to detect the language"))
            })?;

            return Ok((language, s));
        }

        let mut stdin = stdin();
        let mut s = String::new();
        stdin.read_to_string(&mut s)?;

        let language = possible_language.unwrap_or_else(|| {
            ps.find_syntax_by_first_line(&s)
                .ok_or_else(|| format_err!("Failed to detect the language"))
        })?;

        Ok((language, s))
    }

    pub fn theme(&self, ts: &ThemeSet) -> Result<Theme, Error> {
        if let Some(theme) = ts.themes.get(&self.theme) {
            Ok(theme.clone())
        } else {
            Ok(ThemeSet::get_theme(&self.theme)?)
        }
    }

    pub fn get_formatter(&self) -> Result<ImageFormatter, Error> {
        let formatter = ImageFormatterBuilder::new()
            .line_pad(self.line_pad)
            .window_controls(!self.no_window_controls)
            .line_number(!self.no_line_number)
            .font(self.font.clone().unwrap_or_else(|| vec![]))
            .round_corner(!self.no_round_corner)
            .window_controls(!self.no_window_controls)
            .shadow_adder(self.get_shadow_adder())
            .tab_width(self.tab_width)
            .highlight_lines(self.highlight_lines.clone().unwrap_or_else(|| vec![]));

        Ok(formatter.build()?)
    }

    pub fn get_shadow_adder(&self) -> ShadowAdder {
        ShadowAdder::new()
            .background(self.background)
            .shadow_color(self.shadow_color)
            .blur_radius(self.shadow_blur_radius)
            .pad_horiz(self.pad_horiz)
            .pad_vert(self.pad_vert)
            .offset_x(self.shadow_offset_x)
            .offset_y(self.shadow_offset_y)
    }

    pub fn get_expanded_output(&self) -> Option<PathBuf> {
        let need_expand = self.output.as_ref().map(|p| p.starts_with("~")) == Some(true);

        if let (Ok(home_dir), true) = (std::env::var("HOME"), need_expand) {
            self.output
                .as_ref()
                .map(|p| p.to_string_lossy().replacen("~", &home_dir, 1).into())
        } else {
            self.output.clone()
        }
    }
}

config.rsでは CLIのオプションとして渡されたものをチェックしたりConfig構造体にbindingしています。それらはclapというライブラリを用いて実現しているみたいです。

clap

clapでは、CLIの設定値を構造体として定義して、parse()によってその設定値を取得することができます

https://github.com/clap-rs/clap

以下のように構造体にinputを埋め込んでます

// (Full example with detailed comments in examples/01d_quick_example.rs)
//
// This example demonstrates clap's full 'custom derive' style of creating arguments which is the
// simplest method of use, but sacrifices some flexibility.
use clap::Clap;

/// This doc string acts as a help message when the user runs '--help'
/// as do all doc strings on fields
#[derive(Clap)]
#[clap(version = "1.0", author = "Kevin K.")]
struct Opts {
    /// Sets a custom config file. Could have been an Option<T> with no default too
    #[clap(short = "c", long = "config", default_value = "default.conf")]
    config: String,
    /// Some input. Because this isn't an Option<T> it's required to be used
    input: String,
    /// A level of verbosity, and can be used multiple times
    #[clap(short = "v", long = "verbose", parse(from_occurrences))]
    verbose: i32,
    #[clap(subcommand)]
    subcmd: SubCommand,
}

#[derive(Clap)]
enum SubCommand {
    /// A help message for the Test subcommand
    #[clap(name = "test", version = "1.3", author = "Someone Else")]
    Test(Test),
}

/// A subcommand for controlling testing
#[derive(Clap)]
struct Test {
    /// Print debug info
    #[clap(short = "d")]
    debug: bool
}

fn main() {
    let opts: Opts = Opts::parse();

    // Gets a value for config if supplied by user, or defaults to "default.conf"
    println!("Value for config: {}", opts.config);
    println!("Using input file: {}", opts.input);

    // Vary the output based on how many times the user used the "verbose" flag
    // (i.e. 'myprog -v -v -v' or 'myprog -vvv' vs 'myprog -v'
    match opts.verbose {
        0 => println!("No verbose info"),
        1 => println!("Some verbose info"),
        2 => println!("Tons of verbose info"),
        3 | _ => println!("Don't be crazy"),
    }

    // You can handle information about subcommands by requesting their matches by name
    // (as below), requesting just the name used, or both at the same time
    match opts.subcmd {
        SubCommand::Test(t) => {
            if t.debug {
                println!("Printing debug info...");
            } else {
                println!("Printing normally...");
            }
        }
    }
}

https://github.com/clap-rs/clap#quick-example

let (ps, ts) = init_syntect();

その後に init_syntectという関数を叩いてます。syntect は任意の言語向けのシンタックスハイライトライブラリのようです。

/// Load the default SyntaxSet and ThemeSet.
pub fn init_syntect() -> (SyntaxSet, ThemeSet) {
    (
        dumps::from_binary(include_bytes!("../assets/syntaxes.bin")), // 1
        dumps::from_binary(include_bytes!("../assets/themes.bin")),  // 2
    )
}

上記の1および2において利用されているsyntectのdumpsモジュールは、高速起動に関するモジュールです。

Methods for dumping serializable structs to a compressed binary format These are used to load and store the dumps used for fast startup times.

https://docs.rs/syntect/3.3.0/syntect/#modules

で、2の処理では前もってassetsディレクトリに保存されているDraculaのテーマ(バイナリで加工されている)を読み込んでいるようです

https://github.com/Aloxaf/silicon/blob/master/assets/themes/Dracula.tmTheme

1は読み込んではいますが元ファイルがないので推測ですが、 シンタックスに適用するテーマでしょう

let highlight = LinesWithEndings::from(&code).map(|line| h.highlight(line, &ps)).collect::<Vec<_>>();

    let mut h = HighlightLines::new(syntax, &theme); // 1
    let highlight = LinesWithEndings::from(&code)
        .map(|line| h.highlight(line, &ps))
        .collect::<Vec<_>>();

siliconのコードでも最大のコアな部分です。 1にてテーマ・フォントを適用したインスタンスをつくり、ソースコード各行に対して適用しています。

https://docs.rs/syntect/3.3.0/syntect/easy/struct.HighlightLines.html

h.highlight(line, &ps) の返り値は(Style, &str)になっていてタプルの第1パラメータに適用されたものが入っています

formatter.format

Vec<(Style, &str)>を画像データに変換しています

実際の処理は config.rsにて記載されており、その中ではさらに formatter.rs を呼び出しています

use crate::formatter::{ImageFormatter, ImageFormatterBuilder};

// ...

pub fn get_formatter(&self) -> Result<ImageFormatter, Error> {
        let formatter = ImageFormatterBuilder::new()
            .line_pad(self.line_pad)
            .window_controls(!self.no_window_controls)
            .line_number(!self.no_line_number)
            .font(self.font.clone().unwrap_or_else(|| vec![]))
            .round_corner(!self.no_round_corner)
            .window_controls(!self.no_window_controls)
            .shadow_adder(self.get_shadow_adder())
            .tab_width(self.tab_width)
            .highlight_lines(self.highlight_lines.clone().unwrap_or_else(|| vec![]));

        Ok(formatter.build()?)
    }

formatter.rsでは、builderパターンを使って出力する画像を定義しています。builderパターンは気持ち良くかけて大好きです。

Rustでのbuilderパターンは、初期化時にselfインスタンスをreturnし、加工する関数では mut self のプロパティを変更してselfをreturnして実現できるようです。とても勉強になる

// FIXME: cannot use `ImageFormatterBuilder::new().build()` bacuse cannot infer type for `S`
impl<S: AsRef<str> + Default> ImageFormatterBuilder<S> {
    pub fn new() -> Self {
        Self {
            line_pad: 2,
            line_number: true,
            window_controls: true,
            round_corner: true,
            tab_width: 4,
            ..Default::default()
        }
    }

    /// Whether show the line number
    pub fn line_number(mut self, show: bool) -> Self {
        self.line_number = show;
        self
    }

dump_image_to_clipboard(&image)?

オプションで指定すれば、クリップボードに画像をコピーすることもできます

クリップボードにコピーする処理はOS依存なので、attributeでOSごとに打ち分けていた。

#[cfg(target_os = "linux")]
pub fn dump_image_to_clipboard(image: &DynamicImage) -> Result<(), Error> {
    let mut temp = tempfile::NamedTempFile::new()?;
    image.write_to(&mut temp, ImageOutputFormat::PNG)?;
    Command::new("xclip")
        .args(&[
            "-sel",
            "clip",
            "-t",
            "image/png",
            temp.path().to_str().unwrap(),
        ])
        .status()
        .map_err(|e| format_err!("Failed to copy image to clipboard: {}", e))?;
    Ok(())
}

#[cfg(target_os = "macos")]
pub fn dump_image_to_clipboard(image: &DynamicImage) -> Result<(), Error> {
    let mut temp = tempfile::NamedTempFile::new()?;
    image.write_to(&mut temp, ImageOutputFormat::PNG)?;
    unsafe {
        Pasteboard::Image.copy(temp.path().to_str().unwrap());
    }
    Ok(())
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn dump_image_to_clipboard(_image: &DynamicImage) -> Result<(), Error> {
    Err(format_err!(
        "This feature hasn't been implemented for your system"
    ))
}

macでは Pasteboard というライブラリを使ってクリップボードに保存している。

https://github.com/segeljakt/pasteboard

このライブラリの中で画像ファイルを/tmpに作って、AppKitのNSPasteboardっていうのにデータをぶっ込んでいるみたい。

AppKitが提供しているものをRust経由で呼び出せるのは知らなかった。

エラーハンドリングについて

エラーハンドリング全くわからないのでエラーハンドリングについて注目してみました。

独自error構造体の定義はerror.rsにありました。

#[derive(Debug)]
pub enum FontError {
    SelectionError(SelectionError),
    FontLoadingError(FontLoadingError),
}

impl Error for FontError {}

impl Display for FontError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FontError::SelectionError(e) => write!(f, "Font error: {}", e),
            FontError::FontLoadingError(e) => write!(f, "Font error: {}", e),
        }
    }
}

impl From<SelectionError> for FontError {
    fn from(e: SelectionError) -> Self {
        FontError::SelectionError(e)
    }
}

impl From<FontLoadingError> for FontError {
    fn from(e: FontLoadingError) -> Self {
        FontError::FontLoadingError(e)
    }
}

独自エラーをenumでで実装するパターンはthe bookのやり方と同じような感じです

ポイントは、 Fromトレイトを実装することで、 SelectionErrorからFontErrorにキャスト可能にしているところですね。

impl From<SelectionError> for FontError {
    fn from(e: SelectionError) -> Self {
        FontError::SelectionError(e)
    }
}

余談ですが、the bookでは上記の実装に加えて error::Errorトレイトに source(&self) -> Option<&(dyn error:Error + 'static)>を生やしています

impl error::Error for DoubleError {
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
        match *self {
            DoubleError::EmptyVec => None,
            // The cause is the underlying implementation error type. Is implicitly
            // cast to the trait object `&error::Error`. This works because the
            // underlying type already implements the `Error` trait.
            DoubleError::Parse(ref e) => Some(e),
        }
    }
}

source()に関しては error::Errorのdocに詳しく書かれています

The source method is generally used when errors cross “abstraction boundaries”. If one module must report an error that is caused by an error from a lower-level module, it can allow access to that error via the source method. This makes it possible for the high-level module to provide its own errors while also revealing some of the implementation for debugging via source chains.

要するに、source()を提供することによって、高レベルモジュール(エラーをキャッチする側)が、中身のエラーインスタンスを取得する機会を得ているということでしょう(ざっくり)

他にfn description(&self) -> &str という関数もあったのですが、softにdeprecatedらしいのでこれは無視して良いでしょう

まとめ

ざっくりと学んだこと↓

  • Cargo.tomlの featuresについて学んだ
  • env_loggerについて学んだ
  • clapの使い方を学んだ
  • AppleKitをRustから操作できるRustライブラリがあることを知った
  • RustでのBuilderパターンを学んだ
  • 実践的なエラーハンドリングの方法を学んだ

今回は、粗めに気になったところをざっとピックアップしました。基本を知らないことを痛感しますね。。今回の題材のsiliconは、お手頃な規模のリポジトリで本当にコードを追い易かったです。contributorsの方々に本当に感謝しています 🙇‍♂️

MIT License

Copyright (c) 2019-2020 Aloxaf

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
tech  Rust  oss 

See also