DEV Community

Cover image for Build.rs-ing Documentation with Cuelang

Build.rs-ing Documentation with Cuelang

I have a little utility called tmplr which purpose is to generate files/file sets from a human-readable templates (check it out, it's neat!).

It is at amazing version 0.0.9, and one thing that annoyed me every single time was the need of using version information in few places:

  • HELP showed on -h or --help
  • README.md (that included copy of help text)
  • Cargo.toml
  • Local git tag (git tag v0.0.9)
  • Pushed tags it to the GitHub

It's not that bad but it's an overhead I wanted to eliminate. Since I'm a huge fan of CUE and I generate most of the configs/data I need with it I thought - heck, why not?

Thankfully Rust provides ability to have a pre-build "scripts" (it's actually full Rust program) which allowed me to do not only version injection but also:

  • Generate full README.md
  • Generate CHANGELOG.md based on smarter entries
  • Inject Rust code into src/main.rs (that was really neat)
  • Reuse part of the documentation in other places

Long story short, let me show you my build.rs:

use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;

fn main() {
    // Injects "HELP" into src/main.rs
    prepare_documentation_code();

    generate_doc("README.md", "readme.full");
    generate_doc("CHANGELOG.md", "changelog.text");

    println!("cargo:rerun-if-changed=cue");
}

fn generate_doc(file_in_manifest: &str, eval_command: &str) {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let readme_path = Path::new(&manifest_dir).join(file_in_manifest);
    let output = Command::new("cue")
        .args([
            "export",
            "-e",
            eval_command,
            "--out",
            "text",
            "./cue:documentation",
        ])
        .output()
        .expect("Failed to execute cue command");

    if !output.status.success() {
        panic!(
            "Cue {} generation failed:\n{}",
            file_in_manifest,
            String::from_utf8_lossy(&output.stderr)
        );
    }
    fs::write(&readme_path, &output.stdout).expect("Failed to write generated file");
}

fn prepare_documentation_code() {
    let out_dir = env::var_os("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("generated_docs.rs");

    let output = Command::new("cue")
        .args([
            "export",
            "-e",
            "help.code",
            "--out",
            "text",
            "./cue:documentation",
        ])
        .output()
        .expect("Failed to execute cue command");

    if !output.status.success() {
        panic!(
            "Cue generation failed:\n{}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    fs::write(&dest_path, &output.stdout).expect("Failed to write generated file");
}
Enter fullscreen mode Exit fullscreen mode

Documentation code is bit different because it's a source code that's going to be injected into main.rs using following code:

include!(concat!(env!("OUT_DIR"), "/generated_docs.rs"));
Enter fullscreen mode Exit fullscreen mode

This generated_docs is actual build artifact I don't want to submit to VCS thus I don't plainly generate it. Changelog and Readme is another story, this should be checked in, so I generate them direclty in MANIFEST_DIR (i.e. Project's root).

I won't show my CUE files this time, as they're mostly filled with data and split across multiple files. Here's how my cue/ directory looks like:

cue
├── changelog.cue
├── documentation.cue
├── help.cue
├── LICENSE.cue
└── readme.cue
Enter fullscreen mode Exit fullscreen mode

documentation.cue is a root package file - help, changelog and readme are files containing data (with some smart transformations)

One piece you might be interested in is the final README.md outline:

// ...rest of file, sections etc.
full: """
  # tmplr
  \(sections.tmplr)
  ## Why tmplr?
  \(sections.why_tmplr)
  # Quick Start
  \(sections.quick_start)
  # CLI Help
  ‎`‎``
  \(help.text)
  `‎``
  # Installation
  \(sections.installation)
  # Usage (extended)
  ## .tmplr files
  \(sections.tmplr_files)
  ### Section Types
  \(sections.section_types)
  ### Magic Variables
  \(sections.magic_variables)
  ## CLI
  \(sections.cli)
  ## Templates directory
  \(sections.templates_directory)
  # TODO
  \(sections.todo)
  """
Enter fullscreen mode Exit fullscreen mode

Here you can clearly see that "CLI Help" section is reading directly from the help.text. Neat, right? No more updating README.md by hand, I just need to make sure I build before I push :)

btw. final result is already pushed to the root repo where you can see the end result and full .cue files for inspection. Project extension (see commit for full scope) took around 1.5h to develop, most of the time was spent porting the data from .md to .cue files

Top comments (0)