Developer tools

Changelog

Conventional Commits read: each message's type, scope and breaking change, the next semantic version, release notes in Markdown, and a lint for commit messages.

Version 1.0.0 · 5 functions · 0 crates

#changelog#git#releases#versions#markdown

Functions

What use changelog.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use changelog_parse, changelog_next_version from changelog (Quillo 0.6).

changelog_parse(message: str) → any

One commit message read: { type, scope, breaking, breaking_note, description, body, footers, issues, conventional }. breaking from a ! before the colon or a BREAKING CHANGE: footer; issues from Closes, Fixes, Refs footers. A message that is not a Conventional Commit has conventional false, type null and its first line as the description.

changelog_next_version(current: str, commits: list) → anyor an error

The next version after current ("1.4.2", a leading "v" kept) for these commits (messages, or maps with message): { version, bump }, bump "major" for a breaking change, "minor" for a feature, "patch" for a fix, a perf change or a revert, "none" (version unchanged) for the rest. Below 1.0.0 a breaking change bumps the minor and a feature the patch, as semver's 0.x versions do.

changelog_group(commits: list) → anyor an error

The commits grouped for release notes: { breaking, feat, fix, perf, revert, refactor, docs, other }, each a list of parsed commits (as changelog_parse gives them, with id when the commit had one). other holds the rest, chores and tests included.

changelog_markdown(version: str, date: str, commits: list, repo_url: str) → stror an error

Release notes in Markdown for version on date ("2026-10-29"): ## version (date), then Breaking Changes, Features, Bug Fixes, Performance, Reverts, Code Refactoring and Documentation, scopes in bold. With repo_url ("https://github.com/acme/shop", or "") commit ids become links. Chores, tests, CI, style and non-conventional commits are left out.

changelog_lint(message: str) → list

What is wrong with a commit message against Conventional Commits (empty when it is fine), as commitlint's usual rules say it: a missing or unknown type, an empty description, one ending with a full stop, a header over 100 characters, no blank line before the body.

Get it

$ quillo install changelog               # changelog.qwer into this folder$ quillo install changelog --example     # and its example, changelog.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/changelog.qwer$ curl -O https://quillolang.org/plugins/files/changelog.qwe

Then, in your program, all of it or only what you call:

main.qwe
use changelog.qweruse changelog_parse, changelog_next_version from changelog      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

changelog.qwe
// A release from the commits since the last tag: the next version, the release notes, and// a check of a commit message as a pre-commit hook would make it.////   quillo run changelog.qwe      then open http://localhost:8080/releaseuse "changelog.qwer"let COMMITS = [    { id: "a1b2c3d4e5", message: "feat(cart): keep the cart for 30 days\n\nCarts were kept for a session only.\n\nCloses #412" },    { id: "b2c3d4e5f6", message: "fix(checkout): round VAT per line, not per order" },    { id: "c3d4e5f6a7", message: "feat(api)!: orders list paged by cursor\n\nBREAKING CHANGE: `page` is gone; pass `after` from the last answer." },    { id: "d4e5f6a7b8", message: "perf: index orders by customer" },    { id: "e5f6a7b8c9", message: "chore: bump dependencies" },    { id: "f6a7b8c9d0", message: "Merge branch 'main' into cart" },]route GET /release {    let next = changelog_next_version("v2.3.1", COMMITS)?    respond(200, changelog_markdown(next.version, "2026-10-29", COMMITS, "https://github.com/acme/shop")?, { "content-type": "text/markdown; charset=utf-8" })}route GET /next = {    after_2_3_1: changelog_next_version("v2.3.1", COMMITS)?,    fixes_only: changelog_next_version("2.3.1", ["fix: a typo in the receipt", "docs: the README"])?,    before_1_0: changelog_next_version("0.4.0", ["feat!: new config file"])?,}route GET /parse = changelog_parse(COMMITS[0].message)route GET /groups {    let g = changelog_group(COMMITS)?    { breaking: g.breaking | map((c) => c.breaking_note), feat: g.feat | map((c) => c.description), other: g.other | map((c) => c.description) }}route GET /lint = {    good: changelog_lint("fix(auth): refresh the token before it expires"),    bad: changelog_lint("Fixed the login.\nIt was broken"),    unknown: changelog_lint("feature: dark mode"),}
$ quillo run changelog.qwe$ curl 'localhost:8080/release'$ curl 'localhost:8080/next'$ curl 'localhost:8080/parse'$ curl 'localhost:8080/lint'

Source

changelog.qwer
//! Conventional Commits read: each message's type, scope and breaking change, the next semantic version, and the release notes in Markdown.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};const TYPES: [&str; 11] = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore", "revert"];struct Commit {    kind: Option<String>,    scope: Option<String>,    breaking: Option<String>,    description: String,    body: String,    footers: Vec<(String, String)>,    id: Option<String>,}fn is_footer_start(line: &str) -> Option<(String, String)> {    if let Some(rest) = line.strip_prefix("BREAKING CHANGE: ").or_else(|| line.strip_prefix("BREAKING-CHANGE: ")) {        return Some(("BREAKING CHANGE".into(), rest.to_string()));    }    let (tok, sep_len) = if let Some(i) = line.find(": ") {        (&line[..i], (i, 2))    } else if let Some(i) = line.find(" #") {        (&line[..i], (i, 1))    } else {        return None;    };    if tok.is_empty() || !tok.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {        return None;    }    Some((tok.to_string(), line[sep_len.0 + sep_len.1..].trim().to_string()))}fn parse(message: &str, id: Option<String>) -> Commit {    let message = message.replace("\r\n", "\n");    let mut lines = message.trim().lines();    let header = lines.next().unwrap_or("").trim().to_string();    let rest: Vec<&str> = lines.collect();    // Footers: the last paragraph, when its lines are `Token: value` or `Token #value` (with continuation lines).    let mut footers: Vec<(String, String)> = vec![];    let mut body_lines: Vec<&str> = rest.clone();    if let Some(start) = rest.iter().rposition(|l| l.trim().is_empty()).map(|i| i + 1).or(Some(0)) {        let para = &rest[start..];        if para.first().is_some_and(|l| is_footer_start(l).is_some()) {            for l in para {                match is_footer_start(l) {                    Some(f) => footers.push(f),                    None => {                        if let Some(last) = footers.last_mut() {                            last.1.push('\n');                            last.1.push_str(l);                        }                    }                }            }            body_lines = rest[..start].to_vec();        }    }    let body = body_lines.join("\n").trim().to_string();    let mut c = Commit { kind: None, scope: None, breaking: None, description: header.clone(), body, footers, id };    if let Some(colon) = header.find(": ") {        let head = &header[..colon];        let (head, bang) = match head.strip_suffix('!') {            Some(h) => (h, true),            None => (head, false),        };        let (kind, scope) = match head.find('(') {            Some(p) if head.ends_with(')') => (&head[..p], Some(head[p + 1..head.len() - 1].to_string())),            _ => (head, None),        };        if !kind.is_empty() && kind.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {            c.kind = Some(kind.to_lowercase());            c.scope = scope.filter(|s| !s.is_empty());            c.description = header[colon + 2..].trim().to_string();            if bang {                c.breaking = Some(c.description.clone());            }        }    }    if let Some((_, v)) = c.footers.iter().find(|(k, _)| k == "BREAKING CHANGE") {        c.breaking = Some(v.clone());    }    c}fn commit_of(v: &Value) -> Result<Commit, String> {    match v {        Value::String(s) => Ok(parse(s, None)),        Value::Object(o) => {            let m = o.get("message").or_else(|| o.get("summary")).and_then(Value::as_str).ok_or("a commit is its message, or a map with `message`")?;            let id = o.get("short").or_else(|| o.get("id")).or_else(|| o.get("hash")).and_then(Value::as_str).map(|s| s.chars().take(7).collect());            Ok(parse(m, id))        }        _ => Err("a commit is its message, or a map with `message`".into()),    }}fn to_value(c: &Commit) -> Value {    let issues: Vec<String> = c        .footers        .iter()        .filter(|(k, _)| matches!(k.to_lowercase().as_str(), "closes" | "fixes" | "refs" | "resolves" | "close" | "fix"))        .flat_map(|(_, v)| v.split([',', ' ']).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::<Vec<_>>())        .collect();    let footers: serde_json::Map<String, Value> = c.footers.iter().map(|(k, v)| (k.clone(), json!(v))).collect();    json!({        "type": c.kind, "scope": c.scope, "breaking": c.breaking.is_some(), "breaking_note": c.breaking,        "description": c.description, "body": c.body, "footers": footers, "issues": issues,        "conventional": c.kind.is_some(), "id": c.id,    })}/// One commit message read: `{ type, scope, breaking, breaking_note, description, body, footers, issues, conventional }`. `breaking` from a `!`/// before the colon or a `BREAKING CHANGE:` footer; `issues` from `Closes`, `Fixes`, `Refs` footers. A message that is not a Conventional/// Commit has `conventional` false, `type` null and its first line as the description.pub fn changelog_parse(message: &str) -> Value {    to_value(&parse(message, None))}fn bump_of(cs: &[Commit]) -> &'static str {    if cs.iter().any(|c| c.breaking.is_some()) {        "major"    } else if cs.iter().any(|c| c.kind.as_deref() == Some("feat")) {        "minor"    } else if cs.iter().any(|c| matches!(c.kind.as_deref(), Some("fix" | "perf" | "revert"))) {        "patch"    } else {        "none"    }}/// The next version after `current` ("1.4.2", a leading "v" kept) for these commits (messages, or maps with `message`): `{ version, bump }`,/// bump "major" for a breaking change, "minor" for a feature, "patch" for a fix, a perf change or a revert, "none" (version unchanged) for the rest./// Below 1.0.0 a breaking change bumps the minor and a feature the patch, as semver's 0.x versions do.pub fn changelog_next_version(current: &str, commits: Vec<serde_json::Value>) -> Result<Value, String> {    let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?;    let v = current.trim();    let (prefix, bare) = match v.strip_prefix('v') {        Some(b) => ("v", b),        None => ("", v),    };    let core = bare.split(['-', '+']).next().unwrap_or("");    let n: Vec<u64> = core.split('.').map(|p| p.parse::<u64>()).collect::<Result<_, _>>().map_err(|_| format!("`{}` is not a version like 1.4.2", current))?;    if n.len() != 3 {        return Err(format!("`{}` is not a version like 1.4.2", current));    }    let (mut ma, mut mi, mut pa) = (n[0], n[1], n[2]);    let mut bump = bump_of(&cs);    if ma == 0 {        bump = match bump {            "major" => "minor",            "minor" => "patch",            b => b,        };    }    match bump {        "major" => (ma, mi, pa) = (ma + 1, 0, 0),        "minor" => (mi, pa) = (mi + 1, 0),        "patch" => pa += 1,        _ => {}    }    let version = if bump == "none" { v.to_string() } else { format!("{}{}.{}.{}", prefix, ma, mi, pa) };    Ok(json!({ "version": version, "bump": bump }))}fn section(kind: &str) -> Option<&'static str> {    Some(match kind {        "feat" => "Features",        "fix" => "Bug Fixes",        "perf" => "Performance",        "revert" => "Reverts",        "refactor" => "Code Refactoring",        "docs" => "Documentation",        _ => return None,    })}fn line(c: &Commit, text: &str, repo_url: &str) -> String {    let scope = c.scope.as_ref().map(|s| format!("**{}:** ", s)).unwrap_or_default();    let id = match (&c.id, repo_url.is_empty()) {        (Some(id), true) => format!(" ({})", id),        (Some(id), false) => format!(" ([{}]({}/commit/{}))", id, repo_url.trim_end_matches('/'), id),        _ => String::new(),    };    format!("* {}{}{}\n", scope, text.lines().next().unwrap_or(""), id)}/// The commits grouped for release notes: `{ breaking, feat, fix, perf, revert, refactor, docs, other }`, each a list of parsed commits/// (as `changelog_parse` gives them, with `id` when the commit had one). `other` holds the rest, chores and tests included.pub fn changelog_group(commits: Vec<serde_json::Value>) -> Result<Value, String> {    let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?;    let mut out = serde_json::Map::new();    for k in ["breaking", "feat", "fix", "perf", "revert", "refactor", "docs", "other"] {        out.insert(k.into(), json!([]));    }    for c in &cs {        if c.breaking.is_some() {            out["breaking"].as_array_mut().unwrap().push(to_value(c));        }        let k = c.kind.as_deref().filter(|k| section(k).is_some()).unwrap_or("other");        out[k].as_array_mut().unwrap().push(to_value(c));    }    Ok(Value::Object(out))}/// Release notes in Markdown for `version` on `date` ("2026-10-29"): `## version (date)`, then Breaking Changes, Features, Bug Fixes,/// Performance, Reverts, Code Refactoring and Documentation, scopes in bold. With `repo_url` ("https://github.com/acme/shop", or "")/// commit ids become links. Chores, tests, CI, style and non-conventional commits are left out.pub fn changelog_markdown(version: &str, date: &str, commits: Vec<serde_json::Value>, repo_url: &str) -> Result<String, String> {    let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?;    let mut md = if date.is_empty() { format!("## {}\n", version) } else { format!("## {} ({})\n", version, date) };    let breaking: Vec<&Commit> = cs.iter().filter(|c| c.breaking.is_some()).collect();    if !breaking.is_empty() {        md.push_str("\n### ⚠ BREAKING CHANGES\n\n");        for c in breaking {            md.push_str(&line(c, c.breaking.as_deref().unwrap_or(""), repo_url));        }    }    for k in ["feat", "fix", "perf", "revert", "refactor", "docs"] {        let these: Vec<&Commit> = cs.iter().filter(|c| c.kind.as_deref() == Some(k)).collect();        if these.is_empty() {            continue;        }        md.push_str(&format!("\n### {}\n\n", section(k).unwrap()));        for c in these {            md.push_str(&line(c, &c.description, repo_url));        }    }    Ok(md)}/// What is wrong with a commit message against Conventional Commits (empty when it is fine), as commitlint's usual rules say it:/// a missing or unknown type, an empty description, one ending with a full stop, a header over 100 characters, no blank line before the body.pub fn changelog_lint(message: &str) -> Vec<String> {    let mut out = vec![];    let c = parse(message, None);    let header = message.trim().lines().next().unwrap_or("");    match &c.kind {        None => out.push("the header is not `type(scope): description`".to_string()),        Some(k) if !TYPES.contains(&k.as_str()) => out.push(format!("unknown type `{}`: {}", k, TYPES.join(", "))),        _ => {}    }    if c.kind.is_some() && c.description.trim().is_empty() {        out.push("the description is empty".into());    }    if c.description.trim_end().ends_with('.') {        out.push("the description ends with a full stop".into());    }    if header.chars().count() > 100 {        out.push(format!("the header is {} characters, over 100", header.chars().count()));    }    if message.trim().lines().nth(1).is_some_and(|l| !l.trim().is_empty()) {        out.push("the body must start after a blank line".into());    }    out}
Git history git_log.qwer · 1.0.0Read a git repository without git installed: recent commits and their authors, the files each changed, and any file as it was at a commit.gitchangeloghistoryfiles7 views · 7 downloadsSemantic versions semver.qwer · 1.0.0Versions compared, matched against ranges as Cargo and npm write them, the newest in a range, and bumped.versionsreleasesupdates7 views · 9 downloads.gitignore matching gitignore.qwer · 1.0.0.gitignore rules matched by a port of git's own wildmatch and checked against `git check-ignore` on thousands of random cases: negation, anchoring, `**`, directories, nested files with a deeper one winning, and folders that cannot come back once excluded. Says which rule decided, and walks a real tree as git would.gitfilesglobtooling4 views · 9 downloadsFeature flags feature_flags.qwer · 1.0.0Feature flags kept as data: kill switches, users named outright, rules on their attributes, and percentage roll-outs and variants that stay the same for each user.flagsexperimentsrulesusersreleases7 views · 10 downloadsGit commands git_cli.qwer · 1.0.0The git command driven through argument lists and parsed to maps: status --porcelain=v2 with staged, unstaged, untracked and conflicted files, branches with upstreams, tags, diff --numstat with renames and binaries, blame --porcelain, show, and a file at a revision. Unlike git_log, it reads working trees; a repository's config cannot run commands through it.gitclirepositoriesdiff2 views · 6 downloadsJupyter notebooks ipynb.qwer · 1.0.0Jupyter notebooks read and written with Jupyter's own layout: cells, outputs, tracebacks without their colour codes, nbformat 3 brought up to 4. Outputs and counts stripped for commits, tagged cells kept, Markdown with fences that survive backticks, a script with magics commented out, and image outputs pulled out to files.jupyternotebookspythonmarkdown3 views · 8 downloads
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On Changelog: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.