///|
/// A public API item extracted from a MoonBit interface file.
///
/// - `kind` is the item category, such as `fn`, `struct`, `enum`,
/// `trait`, `type`, `alias`, `fnalias`, `const`, `let`, `suberror`, or `impl`.
/// - `name` is the normalized item name used to match old and new snapshots.
/// - `signature` is the full declaration text, including a multi-line body
/// for structs, enums, suberrors, and traits.
pub(all) struct ApiItem {
kind : String
name : String
signature : String
} derive(Eq, Debug)
///|
/// A single compatibility finding between two API snapshots.
///
/// - `category` is `"breaking"` or `"compatible"`.
/// - `detail` is a machine-readable reason, such as `removed`,
/// `return-type-changed`, `parameter-removed`, `visibility-tightened`,
/// `field-removed`, `method-changed`, or `variant-added`.
/// - `kind` / `name` identify the affected API item.
/// - `message` is a human-readable explanation for reports.
pub(all) struct ApiChange {
category : String
detail : String
kind : String
name : String
message : String
old_signature : String
new_signature : String
} derive(Eq, Debug)
///|
/// Compatibility report for one old/new API comparison.
///
/// Wraps the list of findings and derives release signals from it:
/// breaking/compatible counts, a SemVer suggestion, a release blocking
/// flag for CI, and Markdown/JSON summaries.
pub(all) struct ApiReport {
changes : Array[ApiChange]
} derive(Debug)
///|
/// Create a normalized public API item.
///
/// Example:
///
/// ```mbt nocheck
/// let item = api_item("fn", "parse", "pub fn parse(String) -> Int")
/// ```
pub fn api_item(kind : String, name : String, signature : String) -> ApiItem {
{ kind, name, signature }
}
///|
/// Parse the full text of a `.mbti` file (as produced by `moon info`)
/// into normalized public API items.
///
/// This is the most convenient entry point when the caller has already
/// read an interface file into a string.
pub fn parse_mbti_content(content : String) -> Array[ApiItem] {
let lines : Array[String] = []
for line in content.split("\n") {
lines.push(owned(line))
}
parse_mbti_items(lines)
}
///|
/// Parse `.mbti` interface lines into normalized public API items.
///
/// Supported declarations include functions, associated functions
/// (`Type::method`), `fnalias`, `typealias`, `type`, `const`, `impl`,
/// and multi-line `struct` / `enum` / `suberror` / `trait` blocks.
/// Comment lines, `package` / `import` headers, and unknown lines are
/// skipped. Attribute lines starting with `#` (such as `#deprecated`)
/// are attached to the declaration that follows them.
pub fn parse_mbti_items(lines : Array[String]) -> Array[ApiItem] {
let items : Array[ApiItem] = []
let mut index = 0
let mut pending_attrs = ""
while index < lines.length() {
let raw = lines[index]
let line = trim_line(raw)
if line.length() == 0 ||
line.has_prefix("//") ||
line.has_prefix("import ") ||
line.has_prefix("package ") {
index = index + 1
continue
}
if line.has_prefix("#") {
pending_attrs = append_line(pending_attrs, line)
index = index + 1
continue
}
if is_block_start(line) {
let (block, next_index) = read_block(lines, index)
let signature = append_line(pending_attrs, block)
pending_attrs = ""
match item_from_signature(signature) {
Some(item) => items.push(item)
None => ()
}
index = next_index
} else if is_public_item_line(line) {
let signature = append_line(pending_attrs, line)
pending_attrs = ""
match item_from_signature(signature) {
Some(item) => items.push(item)
None => ()
}
index = index + 1
} else {
pending_attrs = ""
index = index + 1
}
}
items
}
///|
/// Compare two public API snapshots and produce a compatibility report.
///
/// Classification rules:
/// - An item present in `old_items` but missing in `new_items` is a
/// breaking `removed` change.
/// - An item present in both but with a different signature is classified
/// further (return type, parameters, visibility, fields, methods,
/// variants). Enum variant additions are breaking because exhaustive
/// pattern matching in downstream code may no longer compile.
/// - An item only present in `new_items` is a compatible `added` change.
/// - Marking an existing item with `#deprecated` (body unchanged) is a
/// compatible `deprecated` change.
///
/// Uses `default_compat_policy()` for severity remaps (for example,
/// labeled optional parameter additions are compatible).
pub fn compare_api(
old_items : Array[ApiItem],
new_items : Array[ApiItem],
) -> ApiReport {
compare_api_with_policy(old_items, new_items, default_compat_policy())
}
///|
/// Compare two API snapshots with an explicit compatibility policy.
pub fn compare_api_with_policy(
old_items : Array[ApiItem],
new_items : Array[ApiItem],
policy : CompatPolicy,
) -> ApiReport {
let changes : Array[ApiChange] = []
for old_item in old_items {
match find_item(new_items, old_item.kind, old_item.name) {
None =>
changes.push(
api_change(
"breaking",
"removed",
old_item.kind,
old_item.name,
"\{old_item.kind} \{old_item.name} was removed",
),
)
Some(new_item) =>
if old_item.signature != new_item.signature {
let classification = classify_item_change(
old_item.kind,
old_item.signature,
new_item.signature,
policy,
)
if classification.category != "ignore" {
changes.push(
api_change(
classification.category,
classification.detail,
old_item.kind,
old_item.name,
classification.message,
old_signature=old_item.signature,
new_signature=new_item.signature,
),
)
}
}
}
}
for new_item in new_items {
if find_item(old_items, new_item.kind, new_item.name) is None {
changes.push(
api_change(
"compatible",
"added",
new_item.kind,
new_item.name,
"\{new_item.kind} \{new_item.name} was added",
),
)
}
}
sort_changes(changes)
{ changes, }
}
///|
fn sort_changes(changes : Array[ApiChange]) -> Unit {
let mut i = 0
while i < changes.length() {
let mut j = i + 1
while j < changes.length() {
if change_sort_key(changes[j]) < change_sort_key(changes[i]) {
let tmp = changes[i]
changes[i] = changes[j]
changes[j] = tmp
}
j = j + 1
}
i = i + 1
}
}
///|
fn change_sort_key(change : ApiChange) -> String {
"\{change.category}|\{change.kind}|\{change.name}|\{change.detail}"
}
///|
/// Parse and compare two `.mbti` file contents in one step.
///
/// This is the most convenient library entry when callers already have
/// old/new interface text and do not need intermediate `ApiItem` lists.
///
/// Example:
///
/// ```mbt nocheck
/// let report = compare_mbti_content(old_mbti, new_mbti)
/// assert_eq(report.semver_suggestion(), "major")
/// ```
pub fn compare_mbti_content(
old_content : String,
new_content : String,
) -> ApiReport {
compare_api(parse_mbti_content(old_content), parse_mbti_content(new_content))
}
///|
/// Parse and compare two `.mbti` contents with an explicit policy.
pub fn compare_mbti_content_with_policy(
old_content : String,
new_content : String,
policy : CompatPolicy,
) -> ApiReport {
compare_api_with_policy(
parse_mbti_content(old_content),
parse_mbti_content(new_content),
policy,
)
}
///|
/// Keep only breaking changes. Useful for CI summaries that should ignore
/// compatible additions.
pub fn ApiReport::breaking_only(self : ApiReport) -> ApiReport {
let changes : Array[ApiChange] = []
for change in self.changes {
if change.category == "breaking" {
changes.push(change)
}
}
{ changes, }
}
///|
/// Prefix every change name with `scope::` so multi-file reports remain
/// attributable to a concrete `.mbti` path.
pub fn ApiReport::scoped(self : ApiReport, scope : String) -> ApiReport {
let changes : Array[ApiChange] = []
for change in self.changes {
changes.push(
api_change(
change.category,
change.detail,
change.kind,
"\{scope}::\{change.name}",
"\{scope}: \{change.message}",
old_signature=change.old_signature,
new_signature=change.new_signature,
),
)
}
{ changes, }
}
///|
/// Merge multiple reports into one by concatenating their changes.
pub fn merge_api_reports(reports : Array[ApiReport]) -> ApiReport {
let changes : Array[ApiChange] = []
for report in reports {
for change in report.changes {
changes.push(change)
}
}
{ changes, }
}
///|
/// Breaking report for a `.mbti` file present in the old snapshot but
/// missing from the new one.
pub fn file_removed_report(path : String) -> ApiReport {
{
changes: [
api_change(
"breaking",
"file-removed",
"file",
path,
"mbti file \{path} was removed",
),
],
}
}
///|
/// Compatible report for a `.mbti` file present only in the new snapshot.
pub fn file_added_report(path : String) -> ApiReport {
{
changes: [
api_change(
"compatible",
"file-added",
"file",
path,
"mbti file \{path} was added",
),
],
}
}
///|
/// Count breaking changes in the report.
pub fn ApiReport::breaking_count(self : ApiReport) -> Int {
self.count_category("breaking")
}
///|
/// Count compatible changes (additions only).
pub fn ApiReport::compatible_count(self : ApiReport) -> Int {
self.count_category("compatible")
}
///|
/// Suggest the smallest SemVer bump implied by the report:
/// `"major"` when any breaking change exists, `"minor"` when only
/// compatible changes exist, and `"patch"` when nothing changed.
pub fn ApiReport::semver_suggestion(self : ApiReport) -> String {
if self.breaking_count() > 0 {
"major"
} else if self.compatible_count() > 0 {
"minor"
} else {
"patch"
}
}
///|
/// Short human-readable SemVer advice for CI logs and HTML reports.
pub fn ApiReport::semver_advice(self : ApiReport) -> String {
let bump = self.semver_suggestion()
if bump == "major" {
"Suggested bump: major (breaking public API changes detected)"
} else if bump == "minor" {
"Suggested bump: minor (compatible additions only)"
} else {
"Suggested bump: patch (no public API changes)"
}
}
///|
/// Render one change as a Markdown list line with its kind, name,
/// and human-readable message.
pub fn ApiChange::markdown_line(self : ApiChange) -> String {
"- kind: \{self.kind}, name: \{self.name}, message: \{self.message}"
}
///|
/// Render one change as a JSON object with `category`, `detail`,
/// `kind`, `name`, and `message` fields. String values are escaped.
pub fn ApiChange::json_object(self : ApiChange) -> String {
let mut obj = "{" +
"\"category\":" +
json_string(self.category) +
",\"detail\":" +
json_string(self.detail) +
",\"kind\":" +
json_string(self.kind) +
",\"name\":" +
json_string(self.name) +
",\"message\":" +
json_string(self.message)
if self.old_signature.length() > 0 || self.new_signature.length() > 0 {
obj = obj +
",\"old_signature\":" +
json_string(self.old_signature) +
",\"new_signature\":" +
json_string(self.new_signature)
}
obj + "}"
}
///|
/// Render the report as a Markdown document: a summary header with
/// breaking/compatible counts and the SemVer suggestion, followed by a
/// `## Changes` section listing every change when any exist. Multi-file
/// scopes (`path.mbti::item`) are grouped under `### path` headings.
pub fn ApiReport::markdown_summary(self : ApiReport) -> String {
let mut summary = "# moon_api_guard report\n\n- breaking: \{self.breaking_count()}\n- compatible: \{self.compatible_count()}\n- semver: \{self.semver_suggestion()}\n- \{self.semver_advice()}"
if self.changes.length() > 0 {
summary = summary + "\n\n## Changes\n"
summary = summary + render_grouped_markdown_changes(self.changes)
}
summary
}
///|
/// Render a self-contained interactive HTML report suitable for local
/// demo / CI artifact upload. Includes client-side filter and search.
pub fn ApiReport::html_summary(self : ApiReport) -> String {
let mut body = "\n\n\n\n\nmoon_api_guard report \n\n\n\n\nmoon_api_guard
\nAPI compatibility report
\n\{html_escape(self.semver_advice())}. Use the filters below to isolate breaking changes for review.
\n \n\n\n\n"
if self.changes.length() > 0 {
body = body +
"Changes
\n" +
render_grouped_html_changes(self.changes)
} else {
body = body + "No public API changes detected.
\n"
}
body = body +
"\n\n\n\n\n"
body
}
///|
/// Compact Markdown suitable for GitHub Actions job summaries.
pub fn ApiReport::github_summary(self : ApiReport) -> String {
let mut out = "## moon_api_guard\n\n"
out = out +
"| metric | value |\n| --- | --- |\n| breaking | \{self.breaking_count()} |\n| compatible | \{self.compatible_count()} |\n| semver | \{self.semver_suggestion()} |\n| release_blocked | \{self.release_blocked()} |\n\n"
out = out + self.semver_advice() + "\n"
if self.changes.length() > 0 {
out = out + "\n### Changes\n\n"
let mut i = 0
while i < self.changes.length() && i < 30 {
let change = self.changes[i]
out = out +
"- **\{change.category}** `\{change.name}` — \{change.message} (`\{change.detail}`)\n"
i = i + 1
}
if self.changes.length() > 30 {
out = out + "\n_…and \{self.changes.length() - 30} more findings_\n"
}
}
out
}
///|
/// Whether a release should be blocked in CI. Returns `true` when the
/// report contains at least one breaking change.
pub fn ApiReport::release_blocked(self : ApiReport) -> Bool {
self.breaking_count() > 0
}
///|
/// Render the report as a single-line JSON object for CI consumption.
/// The output contains `breaking`, `compatible`, `semver`,
/// `release_blocked`, and a `changes` array of per-change objects.
pub fn ApiReport::json_summary(self : ApiReport) -> String {
let change_objects : Array[String] = []
for change in self.changes {
change_objects.push(change.json_object())
}
"{" +
"\"breaking\":" +
self.breaking_count().to_string() +
",\"compatible\":" +
self.compatible_count().to_string() +
",\"semver\":" +
json_string(self.semver_suggestion()) +
",\"release_blocked\":" +
json_bool(self.release_blocked()) +
",\"changes\":" +
json_array(change_objects) +
"}"
}
///|
/// Render a SARIF 2.1.0 log suitable for GitHub Code Scanning and CI
/// artifact consumers. Breaking changes use SARIF `error` level while
/// compatible findings use `note`. Scoped directory comparisons attach
/// the originating `.mbti` file as an artifact location.
pub fn ApiReport::sarif_summary(self : ApiReport) -> String {
let results : Array[String] = []
for change in self.changes {
results.push(change.sarif_result())
}
"{" +
"\"version\":\"2.1.0\"," +
"\"$schema\":\"https://json.schemastore.org/sarif-2.1.0.json\"," +
"\"runs\":[{" +
"\"tool\":{\"driver\":{" +
"\"name\":\"moon_api_guard\"," +
"\"informationUri\":\"https://github.com/FidollarinLA/moon_api_guard\"}}," +
"\"results\":" +
json_array(results) +
"}]}"
}
///|
fn ApiChange::sarif_result(self : ApiChange) -> String {
let level = if self.category == "breaking" { "error" } else { "note" }
let (scope, display_name) = split_change_scope(self.name)
let mut result = "{" +
"\"ruleId\":" +
json_string(self.detail) +
",\"level\":" +
json_string(level) +
",\"message\":{\"text\":" +
json_string("\{self.kind} \{display_name}: \{self.message}") +
"},\"properties\":{" +
"\"category\":" +
json_string(self.category) +
",\"kind\":" +
json_string(self.kind) +
",\"name\":" +
json_string(self.name) +
"}"
match scope {
Some(path) =>
result = result +
",\"locations\":[{\"physicalLocation\":{\"artifactLocation\":{\"uri\":" +
json_string(path) +
"}}}]"
None => ()
}
result + "}"
}
///|
fn ApiReport::count_category(self : ApiReport, category : String) -> Int {
let mut count = 0
for change in self.changes {
if change.category == category {
count = count + 1
}
}
count
}
///|
fn json_bool(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
fn json_array(items : Array[String]) -> String {
let mut result = "["
let mut first = true
for item in items {
if !first {
result = result + ","
}
result = result + item
first = false
}
result + "]"
}
///|
fn json_string(text : String) -> String {
let mut result = "\""
for char in text {
if char == '"' {
result = result + "\\\""
} else if char == '\\' {
result = result + "\\\\"
} else if char == '\n' {
result = result + "\\n"
} else {
result = result + char.to_string()
}
}
result + "\""
}
///|
fn html_escape(text : String) -> String {
let mut result = ""
for char in text {
if char == '&' {
result = result + "&"
} else if char == '<' {
result = result + "<"
} else if char == '>' {
result = result + ">"
} else if char == '"' {
result = result + """
} else {
result = result + char.to_string()
}
}
result
}
///|
/// Split a scoped change name into `(file_scope?, display_name)`.
/// Only treats the first `::` as a file scope when the left side looks
/// like a path (contains `/` or ends with `.mbti`).
fn split_change_scope(name : String) -> (String?, String) {
match name.split_once("::") {
Some((left, right)) => {
let scope = owned(left)
if scope.has_suffix(".mbti") || scope.contains("/") {
(Some(scope), owned(right))
} else {
(None, name)
}
}
None => (None, name)
}
}
///|
fn collect_change_scopes(changes : Array[ApiChange]) -> Array[String] {
let scopes : Array[String] = []
for change in changes {
match split_change_scope(change.name) {
(Some(scope), _) =>
if !contains_string(scopes, scope) {
scopes.push(scope)
}
(None, _) => ()
}
}
scopes
}
///|
fn contains_string(items : Array[String], target : String) -> Bool {
for item in items {
if item == target {
return true
}
}
false
}
///|
fn render_grouped_markdown_changes(changes : Array[ApiChange]) -> String {
let scopes = collect_change_scopes(changes)
if scopes.length() == 0 {
let mut out = ""
for change in changes {
out = out + "\n" + change.markdown_line()
}
return out
}
let mut out = ""
for scope in scopes {
out = out + "\n### \{scope}\n"
for change in changes {
match split_change_scope(change.name) {
(Some(s), display) =>
if s == scope {
out = out +
"\n- kind: \{change.kind}, name: \{display}, message: \{change.message}"
}
(None, _) => ()
}
}
}
let mut has_ungrouped = false
for change in changes {
match split_change_scope(change.name) {
(None, _) => {
has_ungrouped = true
break
}
_ => ()
}
}
if has_ungrouped {
out = out + "\n### (other)\n"
for change in changes {
match split_change_scope(change.name) {
(None, _) => out = out + "\n" + change.markdown_line()
_ => ()
}
}
}
out
}
///|
fn render_grouped_html_changes(changes : Array[ApiChange]) -> String {
let scopes = collect_change_scopes(changes)
if scopes.length() == 0 {
let mut out = "\n"
for change in changes {
out = out + html_change_item(change, change.name)
}
return out + "
\n"
}
let mut out = ""
for scope in scopes {
out = out +
"\{html_escape(scope)}
\n\n"
for change in changes {
match split_change_scope(change.name) {
(Some(s), display) =>
if s == scope {
out = out + html_change_item(change, display)
}
(None, _) => ()
}
}
out = out + "
\n"
}
let mut has_ungrouped = false
for change in changes {
match split_change_scope(change.name) {
(None, _) => {
has_ungrouped = true
break
}
_ => ()
}
}
if has_ungrouped {
out = out + "(other)
\n\n"
for change in changes {
match split_change_scope(change.name) {
(None, _) => out = out + html_change_item(change, change.name)
_ => ()
}
}
out = out + "
\n"
}
out
}
///|
fn html_change_item(change : ApiChange, display_name : String) -> String {
let search = html_escape(
"\{change.category} \{change.kind} \{display_name} \{change.message} \{change.detail}",
)
let mut item = "\{html_escape(change.category)}\{html_escape(change.kind)} \{html_escape(display_name)} — \{html_escape(change.message)} (\{html_escape(change.detail)})"
if change.old_signature.length() > 0 || change.new_signature.length() > 0 {
item = item + ""
if change.old_signature.length() > 0 {
item = item +
"old\n\{html_escape(change.old_signature)}"
}
if change.new_signature.length() > 0 {
item = item +
"new\n\{html_escape(change.new_signature)}"
}
item = item + ""
}
item + " \n"
}
///|
/// Whether `path` matches a simple ignore-path pattern.
/// Supports exact match, one `*` wildcard, and `**/` prefix forms.
pub fn path_matches_ignore_pattern(path : String, pattern : String) -> Bool {
if pattern.length() == 0 {
return false
}
if pattern == path {
return true
}
if pattern.has_prefix("**/") {
let rest = pattern[3:].to_owned()
if rest.has_suffix("/**") {
let mid = rest[:rest.length() - 3].to_owned()
return path == mid ||
path.has_prefix(mid + "/") ||
path.has_suffix("/" + mid) ||
path.contains("/" + mid + "/")
}
return path == rest || path.has_suffix("/" + rest)
}
if pattern.has_suffix("/**") {
let prefix = pattern[:pattern.length() - 3].to_owned()
return path == prefix || path.has_prefix(prefix + "/")
}
match pattern.split_once("*") {
Some((prefix, suffix)) => {
let pre = owned(prefix)
let post = owned(suffix)
if post.contains("*") {
false
} else {
path.has_prefix(pre) &&
path.has_suffix(post) &&
path.length() >= pre.length() + post.length()
}
}
None => false
}
}
///|
/// True when `path` matches any pattern in a comma-separated list.
pub fn path_is_ignored(path : String, patterns_csv : String) -> Bool {
for pattern in split_csv_patterns(patterns_csv) {
if path_matches_ignore_pattern(path, pattern) {
return true
}
}
false
}
///|
fn split_csv_patterns(text : String) -> Array[String] {
let parts : Array[String] = []
if text.length() == 0 {
return parts
}
for part in text.split(",") {
let trimmed = part.trim(char_set=" \t\r").to_owned()
if trimmed.length() > 0 {
parts.push(trimmed)
}
}
parts
}
///|
fn api_change(
category : String,
detail : String,
kind : String,
name : String,
message : String,
old_signature? : String = "",
new_signature? : String = "",
) -> ApiChange {
{ category, detail, kind, name, message, old_signature, new_signature }
}
///|
fn append_line(existing : String, line : String) -> String {
if existing.length() == 0 {
line
} else {
existing + "\n" + line
}
}
///|
/// Net change in brace nesting depth contributed by one line.
/// Used to find where a multi-line declaration block ends.
fn brace_delta(line : String) -> Int {
let mut delta = 0
for char in line {
if char == '{' {
delta = delta + 1
} else if char == '}' {
delta = delta - 1
}
}
delta
}
///|
/// Internal result of classifying one signature difference.
priv struct ChangeClassification {
category : String
detail : String
message : String
}
///|
fn owned(text : StringView) -> String {
text.to_owned()
}
///|
/// Decide whether a signature difference is breaking or compatible.
/// Attribute-only `#deprecated` additions are compatible; other body
/// differences start as breaking and may be remapped by `policy`.
fn classify_item_change(
kind : String,
old_signature : String,
new_signature : String,
policy : CompatPolicy,
) -> ChangeClassification {
let old_body = declaration_body(old_signature)
let new_body = declaration_body(new_signature)
if old_body == new_body {
if became_deprecated(old_signature, new_signature) {
{
category: "compatible",
detail: "deprecated",
message: "\{kind} marked as deprecated",
}
} else {
{
category: "compatible",
detail: "attribute-changed",
message: "\{kind} attributes changed",
}
}
} else {
let (detail, message) = classify_signature_change(kind, old_body, new_body)
let category = policy.category_for(detail, "breaking")
{ category, detail, message }
}
}
///|
/// Whether `#deprecated` appears on the new signature but not the old one.
fn became_deprecated(old_signature : String, new_signature : String) -> Bool {
!has_deprecated_attr(old_signature) && has_deprecated_attr(new_signature)
}
///|
fn has_deprecated_attr(signature : String) -> Bool {
for line in signature.split("\n") {
let trimmed = trim_line(owned(line))
if trimmed.has_prefix("#deprecated") {
return true
}
}
false
}
///|
/// Strip leading attribute lines (`#...`) so body-only diffs can be
/// classified separately from deprecation metadata changes.
fn declaration_body(signature : String) -> String {
let mut body = ""
let mut started = false
for line in signature.split("\n") {
let trimmed = trim_line(owned(line))
if !started && trimmed.has_prefix("#") {
continue
}
started = true
body = append_line(body, trimmed)
}
body
}
///|
/// Dispatch a breaking signature difference to a kind-specific
/// classifier and return the `(detail, message)` pair. Visibility
/// tightening (`pub(all)` / `pub(open)` -> `pub`) wins over all other
/// rules. Visibility widening and derive-only diffs are residual
/// classifications when the structural body is otherwise unchanged.
fn classify_signature_change(
kind : String,
old_signature : String,
new_signature : String,
) -> (String, String) {
if visibility_tightened(old_signature, new_signature) {
return (
"visibility-tightened",
"\{kind} visibility tightened from \{visibility_label(old_signature)} to \{visibility_label(new_signature)}",
)
}
let (detail, message) = if kind == "fn" {
classify_function_change(old_signature, new_signature)
} else if kind == "fnalias" {
classify_fnalias_change(old_signature, new_signature)
} else if kind == "struct" || kind == "suberror" {
classify_fielded_change(kind, old_signature, new_signature)
} else if kind == "enum" {
classify_variant_change(kind, old_signature, new_signature)
} else if kind == "trait" {
classify_trait_change(old_signature, new_signature)
} else if kind == "alias" {
classify_alias_change(old_signature, new_signature)
} else if kind == "const" {
classify_const_change(old_signature, new_signature)
} else if kind == "let" {
classify_let_change(old_signature, new_signature)
} else if kind == "type" {
classify_type_change(old_signature, new_signature)
} else if kind == "impl" {
classify_impl_change(old_signature, new_signature)
} else {
("signature-changed", "\{kind} signature changed")
}
if detail == "signature-changed" {
if visibility_widened(old_signature, new_signature) {
return (
"visibility-widened",
"\{kind} visibility widened from \{visibility_label(old_signature)} to \{visibility_label(new_signature)}",
)
}
if derive_clause(old_signature) != derive_clause(new_signature) {
return classify_derive_change(kind, old_signature, new_signature)
}
}
(detail, message)
}
///|
/// Classify struct / suberror body differences by comparing fields by
/// name: removed field, changed field type, then added field.
/// Also covers tuple/newtype structs such as `struct Path(String)`.
fn classify_fielded_change(
kind : String,
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_tuple = tuple_struct_payload(old_signature)
let new_tuple = tuple_struct_payload(new_signature)
if old_tuple.length() > 0 || new_tuple.length() > 0 {
if old_tuple != new_tuple {
if old_tuple.length() == 0 || new_tuple.length() == 0 {
return (
"struct-shape-changed",
"\{kind} changed between record and tuple form",
)
}
return (
"tuple-field-changed",
"\{kind} tuple payload changed from (\{old_tuple}) to (\{new_tuple})",
)
}
return ("signature-changed", "\{kind} signature changed")
}
let old_fields = body_fields(old_signature)
let new_fields = body_fields(new_signature)
for old_field in old_fields {
let old_name = field_name(old_field)
match find_field_by_name(new_fields, old_name) {
None => return ("field-removed", "\{kind} field removed: \{old_field}")
Some(new_field) =>
if new_field != old_field {
return (
"field-type-changed",
"\{kind} field type changed: \{old_name}",
)
}
}
}
for new_field in new_fields {
let new_name = field_name(new_field)
if find_field_by_name(old_fields, new_name) is None {
return ("field-added", "\{kind} field added: \{new_field}")
}
}
("signature-changed", "\{kind} signature changed")
}
///|
/// Payload inside `struct Name(T1, T2)` / newtype forms. Empty when the
/// declaration uses a `{ ... }` record body instead.
fn tuple_struct_payload(signature : String) -> String {
let line = declaration_line(signature)
if line.contains("{") {
return ""
}
let after_keyword = match line.split_once(" struct ") {
Some((_, rest)) => trim_line(owned(rest))
None =>
match line.split_once("struct ") {
Some((_, rest)) => trim_line(owned(rest))
None => return ""
}
}
// Skip the struct name / generics, then read the tuple payload `(...)`.
// Important: do not use the first `(` in the line — `pub(all)` also has one.
match after_keyword.split_once("(") {
Some((_, after_open)) => {
let mut payload = ""
let mut depth = 0
for char in after_open {
if char == '(' || char == '[' {
depth = depth + 1
payload = payload + char.to_string()
} else if char == ')' || char == ']' {
if depth == 0 {
return trim_line(payload)
}
depth = depth - 1
payload = payload + char.to_string()
} else {
payload = payload + char.to_string()
}
}
trim_line(payload)
}
None => ""
}
}
///|
fn find_field_by_name(fields : Array[String], target_name : String) -> String? {
for field in fields {
if field_name(field) == target_name {
return Some(field)
}
}
None
}
///|
/// Classify function signature differences: async / noraise
/// qualifiers, type bounds, value return type, raise clause, then
/// parameters (with optional-labeled awareness).
fn classify_function_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_async = function_is_async(old_signature)
let new_async = function_is_async(new_signature)
if old_async != new_async {
if new_async {
return ("async-added", "function became async")
}
return ("async-removed", "function is no longer async")
}
let old_noraise = function_is_noraise(old_signature)
let new_noraise = function_is_noraise(new_signature)
if old_noraise != new_noraise {
if new_noraise {
return ("noraise-added", "function gained noraise")
}
return ("noraise-removed", "function lost noraise")
}
let old_bounds = function_type_bounds(old_signature)
let new_bounds = function_type_bounds(new_signature)
if old_bounds != new_bounds {
return (
"type-bound-changed",
"function type bounds changed from [\{old_bounds}] to [\{new_bounds}]",
)
}
let old_value = function_value_type(old_signature)
let new_value = function_value_type(new_signature)
if old_value != new_value &&
(old_value.length() > 0 || new_value.length() > 0) {
return (
"return-type-changed",
"return type changed from \{old_value} to \{new_value}",
)
}
let old_raise = function_raise_clause(old_signature)
let new_raise = function_raise_clause(new_signature)
if old_raise != new_raise {
if old_raise.length() == 0 {
return ("raise-added", "function raise clause added: \{new_raise}")
}
if new_raise.length() == 0 {
return ("raise-removed", "function raise clause removed")
}
return (
"raise-type-changed",
"function raise clause changed from \{old_raise} to \{new_raise}",
)
}
classify_parameter_change(old_signature, new_signature)
}
///|
/// Classify `fnalias` target changes after `=`.
fn classify_fnalias_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_target = fnalias_target(old_signature)
let new_target = fnalias_target(new_signature)
if old_target != new_target {
(
"fnalias-target-changed",
"fnalias target changed from \{old_target} to \{new_target}",
)
} else {
("signature-changed", "fnalias signature changed")
}
}
///|
fn fnalias_target(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once("=") {
Some((_, after)) => trim_line(owned(after))
None => ""
}
}
///|
/// Whether the declaration is an async function (`pub async fn` / `async fn`).
fn function_is_async(signature : String) -> Bool {
let line = declaration_line(signature)
line.contains("async fn") || line.contains("async fn[")
}
///|
/// Whether the declaration includes an explicit `noraise` marker.
fn function_is_noraise(signature : String) -> Bool {
let line = declaration_line(signature)
line.contains(" noraise") ||
line.has_suffix("noraise") ||
line.contains("noraise ") ||
line.contains(") noraise")
}
///|
/// Classify `typealias` target changes after `=`.
fn classify_alias_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_target = alias_target(old_signature)
let new_target = alias_target(new_signature)
if old_target != new_target {
(
"alias-target-changed",
"typealias target changed from \{old_target} to \{new_target}",
)
} else {
("signature-changed", "alias signature changed")
}
}
///|
fn alias_target(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once("=") {
Some((_, after)) => trim_line(owned(after))
None => ""
}
}
///|
/// Classify `const` type and value changes.
fn classify_const_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let (old_ty, old_val) = const_parts(old_signature)
let (new_ty, new_val) = const_parts(new_signature)
if old_ty != new_ty {
return (
"const-type-changed",
"const type changed from \{old_ty} to \{new_ty}",
)
}
if old_val != new_val {
return (
"const-value-changed",
"const value changed from \{old_val} to \{new_val}",
)
}
("signature-changed", "const signature changed")
}
///|
/// Classify `pub let` type changes (`.mbti` usually omits the value).
fn classify_let_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_ty = let_type(old_signature)
let new_ty = let_type(new_signature)
if old_ty != new_ty {
return ("let-type-changed", "let type changed from \{old_ty} to \{new_ty}")
}
("signature-changed", "let signature changed")
}
///|
fn let_type(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once(":") {
Some((_, after)) => strip_derive_suffix(trim_line(owned(after)))
None => ""
}
}
///|
/// Classify `type` declarations: opaque vs `type X = Y`, and target
/// changes for transparent / newtype-style aliases.
fn classify_type_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_kind = type_decl_kind(old_signature)
let new_kind = type_decl_kind(new_signature)
if old_kind != new_kind {
return (
"type-kind-changed",
"type kind changed from \{old_kind} to \{new_kind}",
)
}
if old_kind == "alias" {
let old_target = type_alias_target(old_signature)
let new_target = type_alias_target(new_signature)
if old_target != new_target {
return (
"type-alias-target-changed",
"type alias target changed from \{old_target} to \{new_target}",
)
}
}
("signature-changed", "type signature changed")
}
///|
/// `"opaque"` for `type X` / `pub type X`, `"alias"` for `type X = Y`.
fn type_decl_kind(signature : String) -> String {
let line = declaration_line(signature)
if line.contains("=") {
"alias"
} else {
"opaque"
}
}
///|
fn type_alias_target(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once("=") {
Some((_, after)) => strip_derive_suffix(trim_line(owned(after)))
None => ""
}
}
///|
fn strip_derive_suffix(text : String) -> String {
match text.split_once(" derive(") {
Some((before, _)) => trim_line(owned(before))
None => text
}
}
///|
/// Classify `impl` header / `with` method text changes.
fn classify_impl_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_header = impl_header(old_signature)
let new_header = impl_header(new_signature)
if old_header != new_header {
return (
"impl-header-changed",
"impl header changed from \{old_header} to \{new_header}",
)
}
let old_with = impl_with_clause(old_signature)
let new_with = impl_with_clause(new_signature)
if old_with != new_with {
return ("impl-method-changed", "impl with-clause changed")
}
("signature-changed", "impl signature changed")
}
///|
fn impl_header(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once(" with ") {
Some((before, _)) => trim_line(owned(before))
None => line
}
}
///|
fn impl_with_clause(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once(" with ") {
Some((_, after)) => trim_line(owned(after))
None => ""
}
}
///|
fn const_parts(signature : String) -> (String, String) {
let line = declaration_line(signature)
match line.split_once(":") {
Some((_, after_colon)) => {
let rest = trim_line(owned(after_colon))
match rest.split_once("=") {
Some((ty, value)) => (trim_line(owned(ty)), trim_line(owned(value)))
None => (rest, "")
}
}
None => ("", "")
}
}
///|
fn classify_parameter_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_params = split_params(function_params(old_signature))
let new_params = split_params(function_params(new_signature))
for old_param in old_params {
if is_labeled_param(old_param) {
match find_param_by_key(new_params, param_key(old_param)) {
None =>
return (
"parameter-removed",
"function parameter removed: \{old_param}",
)
Some(new_param) =>
if new_param != old_param {
return (
"parameter-changed",
"function parameter changed: \{old_param} -> \{new_param}",
)
}
}
}
}
let old_positional = positional_params(old_params)
let new_positional = positional_params(new_params)
let shared = if old_positional.length() < new_positional.length() {
old_positional.length()
} else {
new_positional.length()
}
for i in 0.. \{new_positional[i]}",
)
}
}
if new_positional.length() < old_positional.length() {
return ("parameter-removed", "function parameter removed")
}
if new_positional.length() > old_positional.length() {
return ("parameter-added", "function parameter added")
}
let mut only_optional_added = true
let mut only_required_labeled_added = true
let mut added_any = false
for new_param in new_params {
if is_labeled_param(new_param) &&
find_param_by_key(old_params, param_key(new_param)) is None {
added_any = true
if is_optional_labeled_param(new_param) {
only_required_labeled_added = false
} else {
only_optional_added = false
}
}
}
if added_any {
if only_optional_added {
return ("optional-parameter-added", "optional function parameter added")
}
if only_required_labeled_added {
return ("labeled-parameter-added", "labeled function parameter added")
}
return ("parameter-added", "function parameter added")
}
("signature-changed", "function signature changed")
}
///|
fn positional_params(params : Array[String]) -> Array[String] {
let result : Array[String] = []
for param in params {
if !is_labeled_param(param) {
result.push(param)
}
}
result
}
///|
fn is_labeled_param(param : String) -> Bool {
is_optional_labeled_param(param) ||
(!param.has_prefix("(") && param.contains(" : "))
}
///|
fn split_params(params : String) -> Array[String] {
let parts : Array[String] = []
if params.length() == 0 {
return parts
}
let mut current = ""
let mut depth = 0
for char in params {
if char == '(' || char == '[' {
depth = depth + 1
current = current + char.to_string()
} else if char == ')' || char == ']' {
depth = depth - 1
current = current + char.to_string()
} else if char == ',' && depth == 0 {
let trimmed = trim_line(current)
if trimmed.length() > 0 {
parts.push(trimmed)
}
current = ""
} else {
current = current + char.to_string()
}
}
let trimmed = trim_line(current)
if trimmed.length() > 0 {
parts.push(trimmed)
}
parts
}
///|
fn is_optional_labeled_param(param : String) -> Bool {
param.contains("? :") || param.contains("?:")
}
///|
fn param_key(param : String) -> String {
let raw = if is_optional_labeled_param(param) {
match param.split_once("?") {
Some((name, _)) => trim_line(owned(name))
None => param
}
} else if param.contains(" : ") {
match param.split_once(" : ") {
Some((name, _)) => trim_line(owned(name))
None => param
}
} else {
param
}
if raw.has_suffix("~") {
raw[:raw.length() - 1].to_owned()
} else {
raw
}
}
///|
fn find_param_by_key(params : Array[String], key : String) -> String? {
for param in params {
if is_labeled_param(param) && param_key(param) == key {
return Some(param)
}
}
None
}
///|
/// Value type before an optional `raise ...` clause.
fn function_value_type(signature : String) -> String {
let full = function_return_type(signature)
match full.split_once(" raise") {
Some((before, _)) => trim_line(owned(before))
None => full
}
}
///|
/// Text after `raise` in the return clause, or empty when absent.
fn function_raise_clause(signature : String) -> String {
let full = function_return_type(signature)
match full.split_once(" raise") {
Some((_, after)) => trim_line(owned(after))
None => ""
}
}
///|
/// Generic bounds inside `fn[...]`, e.g. `Data : ByteSource`.
fn function_type_bounds(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once("fn[") {
Some((_, after)) =>
match after.split_once("]") {
Some((bounds, _)) => trim_line(owned(bounds))
None => ""
}
None => ""
}
}
///|
/// Classify trait body differences by comparing methods by name.
/// Any method removal, change, or addition is breaking, because
/// downstream `impl` blocks must match the trait exactly.
fn classify_trait_change(
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_bounds = trait_supertraits(old_signature)
let new_bounds = trait_supertraits(new_signature)
if old_bounds != new_bounds {
return (
"trait-bound-changed",
"trait supertraits changed from [\{old_bounds}] to [\{new_bounds}]",
)
}
let old_methods = body_fields(old_signature)
let new_methods = body_fields(new_signature)
for old_method in old_methods {
let old_name = field_name(old_method)
match find_field_by_name(new_methods, old_name) {
None => return ("method-removed", "trait method removed: \{old_method}")
Some(new_method) =>
if new_method != old_method {
return ("method-changed", "trait method changed: \{old_name}")
}
}
}
for new_method in new_methods {
let new_name = field_name(new_method)
if find_field_by_name(old_methods, new_name) is None {
return ("method-added", "trait method added: \{new_method}")
}
}
("signature-changed", "trait signature changed")
}
///|
/// Supertrait bounds after `trait Name : ...`, before `{` if present.
fn trait_supertraits(signature : String) -> String {
let line = declaration_line(signature)
let head = match line.split_once("{") {
Some((before, _)) => trim_line(owned(before))
None => line
}
match head.split_once(" : ") {
Some((_, bounds)) => trim_line(owned(bounds))
None => ""
}
}
///|
fn classify_variant_change(
kind : String,
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_variants = body_fields(old_signature)
let new_variants = body_fields(new_signature)
for old_variant in old_variants {
let old_name = variant_name(old_variant)
match find_variant_by_name(new_variants, old_name) {
None =>
return ("variant-removed", "\{kind} variant removed: \{old_variant}")
Some(new_variant) =>
if new_variant != old_variant {
return (
"variant-changed",
"\{kind} variant payload changed: \{old_name}",
)
}
}
}
for new_variant in new_variants {
if find_variant_by_name(old_variants, variant_name(new_variant)) is None {
return ("variant-added", "\{kind} variant added: \{new_variant}")
}
}
("signature-changed", "\{kind} signature changed")
}
///|
fn find_variant_by_name(
variants : Array[String],
target_name : String,
) -> String? {
for variant in variants {
if variant_name(variant) == target_name {
return Some(variant)
}
}
None
}
///|
/// Extract an enum constructor name without its positional or labeled payload.
/// Examples: `Ready` -> `Ready`, `Error(String)` -> `Error`, and
/// `Invalid(reason : String)` -> `Invalid`.
fn variant_name(variant : String) -> String {
first_identifier(trim_line(variant))
}
///|
/// Extract the text between the first `{` and its matching `}`,
/// tracking nested braces. Returns an empty string for single-line
/// declarations without a body.
fn extract_brace_body(signature : String) -> String {
match signature.split_once("{") {
Some((_, after_open)) => {
let mut body = ""
let mut depth = 1
for char in after_open {
if char == '{' {
depth = depth + 1
body = body + char.to_string()
} else if char == '}' {
depth = depth - 1
if depth == 0 {
return body
} else {
body = body + char.to_string()
}
} else {
body = body + char.to_string()
}
}
body
}
None => ""
}
}
///|
/// Collect meaningful body lines of a block declaration: struct fields,
/// enum variants, and trait methods. Comments, `derive(...)` clauses,
/// and attribute lines are skipped.
fn body_fields(signature : String) -> Array[String] {
let body = extract_brace_body(signature)
let fields : Array[String] = []
for line in body.split("\n") {
let trimmed = trim_line(owned(line))
if trimmed.length() > 0 &&
!trimmed.has_prefix("//") &&
!trimmed.has_prefix("derive(") &&
!trimmed.has_prefix("#") {
if trimmed.contains(":") || is_trait_method_line(trimmed) {
fields.push(trimmed)
} else if trimmed.has_suffix(")") || is_variant_name(trimmed) {
fields.push(trimmed)
}
}
}
fields
}
///|
/// Extract the comparable name of a body line: the method name for
/// `fn ...` lines, the field name before `:` for struct fields, or the
/// whole trimmed line for enum variants.
fn field_name(field : String) -> String {
if field.has_prefix("pub fn ") {
name_after(field, "pub fn ")
} else if field.has_prefix("fn ") {
name_after(field, "fn ")
} else if field.contains(":") {
match field.split_once(":") {
Some((name, _)) => trim_line(owned(name))
None => field
}
} else {
trim_line(field)
}
}
///|
fn find_item(items : Array[ApiItem], kind : String, name : String) -> ApiItem? {
for item in items {
if item.kind == kind && item.name == name {
return Some(item)
}
}
None
}
///|
/// Extract the raw parameter list text between the outermost `(` and
/// `)` of a function head, keeping nested brackets intact.
fn function_params(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once("(") {
Some((_, after_open)) => {
let mut params = ""
let mut depth = 0
for char in after_open {
if char == '(' || char == '[' {
depth = depth + 1
params = params + char.to_string()
} else if char == ')' || char == ']' {
if depth == 0 {
return trim_line(params)
}
depth = depth - 1
params = params + char.to_string()
} else {
params = params + char.to_string()
}
}
trim_line(params)
}
None => ""
}
}
///|
/// Split on the `->` that follows the outermost parameter list, not on
/// arrows that appear inside nested function-typed parameters.
fn top_level_return_split(signature : String) -> (String, String)? {
let mut depth = 0
let mut seen_params = false
let mut before = ""
let mut pending_dash = false
let mut after = ""
let mut found = false
for char in signature {
if found {
after = after + char.to_string()
continue
}
if pending_dash {
pending_dash = false
if char == '>' && depth == 0 && seen_params {
found = true
continue
}
before = before + "-" + char.to_string()
continue
}
if char == '(' || char == '[' {
depth = depth + 1
if char == '(' {
seen_params = true
}
before = before + char.to_string()
} else if char == ')' || char == ']' {
if depth > 0 {
depth = depth - 1
}
before = before + char.to_string()
} else if char == '-' {
pending_dash = true
} else {
before = before + char.to_string()
}
}
if found {
Some((before, after))
} else {
None
}
}
///|
/// Extract the return type: the text after the top-level `->`, so nested
/// function types in parameters do not confuse the result.
fn function_return_type(signature : String) -> String {
let line = declaration_line(signature)
match top_level_return_split(line) {
Some((_, after)) => trim_line(after)
None => ""
}
}
///|
/// Whether this line starts a multi-line block declaration that should
/// be read until its closing brace. Single-line forms such as
/// `pub type UUID` or `pub(all) struct Path(String)` must not match.
fn is_block_start(line : String) -> Bool {
line.contains("{") &&
(
line.contains(" struct ") ||
line.contains(" enum ") ||
line.contains(" suberror ") ||
line.contains(" trait ") ||
line.has_prefix("trait ")
)
}
///|
/// Whether this single line declares a public API item worth tracking.
/// The final clause accepts associated-function lines like
/// `Type::method(...) -> T` that appear without a `pub fn` prefix.
fn is_public_item_line(line : String) -> Bool {
line.has_prefix("pub fn ") ||
line.has_prefix("pub fn[") ||
line.has_prefix("pub async fn ") ||
line.has_prefix("pub async fn[") ||
line.has_prefix("pub fnalias ") ||
line.has_prefix("pub(all) fnalias ") ||
line.has_prefix("pub type ") ||
line.has_prefix("pub(all) type ") ||
line.has_prefix("pub typealias ") ||
line.has_prefix("pub(all) typealias ") ||
line.has_prefix("pub const ") ||
line.has_prefix("pub let ") ||
line.has_prefix("pub impl ") ||
line.has_prefix("impl ") ||
line.has_prefix("pub(all) struct ") ||
line.has_prefix("pub struct ") ||
line.has_prefix("pub(all) enum ") ||
line.has_prefix("pub enum ") ||
line.has_prefix("pub(all) suberror ") ||
line.has_prefix("pub suberror ") ||
line.has_prefix("pub trait ") ||
line.has_prefix("pub(open) trait ") ||
line.has_prefix("pub(all) trait ") ||
(
!line.has_prefix("pub fn ") &&
!line.has_prefix("pub fn[") &&
!line.has_prefix("pub async fn ") &&
!line.has_prefix("pub async fn[") &&
line.contains("::") &&
line.contains("(") &&
line.contains("->")
)
}
///|
fn is_trait_method_line(line : String) -> Bool {
line.has_prefix("fn ") ||
line.has_prefix("pub fn ") ||
(
!line.contains(":") &&
line.contains("(") &&
line.contains("->") &&
!line.has_prefix("pub ") &&
!line.has_prefix("impl ")
)
}
///|
fn is_variant_name(line : String) -> Bool {
let trimmed = trim_line(line)
trimmed.length() > 0 &&
!trimmed.contains(" ") &&
!trimmed.contains(":") &&
!trimmed.contains("(")
}
///|
/// Map one declaration (single line or multi-line block, possibly with
/// leading attribute lines) to a normalized `ApiItem`. Returns `None`
/// for lines that are not recognized public declarations.
fn item_from_signature(signature : String) -> ApiItem? {
let first_line = declaration_line(signature)
if first_line.contains(" struct ") {
Some(
api_item("struct", name_after_keyword(signature, " struct "), signature),
)
} else if first_line.contains(" enum ") {
Some(api_item("enum", name_after_keyword(signature, " enum "), signature))
} else if first_line.contains(" suberror ") {
Some(
api_item(
"suberror",
name_after_keyword(signature, " suberror "),
signature,
),
)
} else if first_line.contains(" trait ") {
Some(api_item("trait", name_after_keyword(signature, " trait "), signature))
} else if first_line.has_prefix("trait ") {
Some(api_item("trait", name_after_keyword(signature, "trait "), signature))
} else if first_line.contains("fnalias ") {
Some(api_item("fnalias", name_after_fnalias(signature), signature))
} else if first_line.contains("pub async fn ") ||
first_line.contains("pub async fn[") ||
first_line.contains("pub fn ") ||
first_line.contains("pub fn[") ||
first_line.contains("::") {
Some(api_item("fn", name_after_function(signature), signature))
} else if first_line.contains(" typealias ") {
Some(
api_item("alias", name_after_keyword(signature, " typealias "), signature),
)
} else if first_line.contains(" type ") {
Some(api_item("type", name_after_keyword(signature, " type "), signature))
} else if first_line.has_prefix("pub const ") {
Some(
api_item("const", name_after_keyword(signature, "pub const "), signature),
)
} else if first_line.has_prefix("pub let ") {
Some(api_item("let", name_after_keyword(signature, "pub let "), signature))
} else if first_line.has_prefix("pub impl ") || first_line.has_prefix("impl ") {
Some(api_item("impl", name_after_impl(signature), signature))
} else {
None
}
}
///|
/// The first non-empty, non-attribute line of a signature: the line
/// that actually carries the declaration keyword and name.
fn declaration_line(signature : String) -> String {
for line in signature.split("\n") {
let trimmed = trim_line(owned(line))
if trimmed.length() > 0 && !trimmed.has_prefix("#") {
return trimmed
}
}
""
}
///|
fn name_after_fnalias(signature : String) -> String {
let line = declaration_line(signature)
if line.contains("fnalias ") {
name_after(line, "fnalias ")
} else {
""
}
}
///|
fn name_after_function(signature : String) -> String {
let line = declaration_line(signature)
if line.contains("::") {
match line.split_once("::") {
Some((type_part, rest)) => {
let type_name = associated_type_name(trim_line(owned(type_part)))
let method_name = first_identifier(trim_line(owned(rest)))
if type_name.length() > 0 {
"\{type_name}::\{method_name}"
} else {
method_name
}
}
None => ""
}
} else if line.contains("pub async fn[") {
match line.split_once("]") {
Some((_, after_bounds)) =>
first_identifier(trim_line(owned(after_bounds)))
None => ""
}
} else if line.contains("pub fn[") {
match line.split_once("]") {
Some((_, after_bounds)) =>
first_identifier(trim_line(owned(after_bounds)))
None => ""
}
} else if line.contains("pub async fn ") {
name_after(line, "pub async fn ")
} else if line.contains("pub fn ") {
name_after(line, "pub fn ")
} else if line.contains("fn ") {
name_after(line, "fn ")
} else {
""
}
}
///|
/// Build the identity of an `impl` item as `"Trait for Type"` so the
/// same trait implemented for different types compares independently.
fn name_after_impl(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once(" for ") {
Some((head, tail)) => {
let impl_part = trim_line(owned(head))
let trait_part = name_after(impl_part, "impl ")
let trait_name = match trait_part.split_once(" with ") {
Some((before, _)) => trim_line(owned(before))
None => trait_part
}
let type_name = first_identifier(trim_line(owned(tail)))
if type_name.length() > 0 {
"\{trait_name} for \{type_name}"
} else {
trait_name
}
}
None => first_identifier(name_after(line, "impl "))
}
}
///|
/// Extract the receiver type name from the part before `::` in an
/// associated function declaration such as `pub fn ApiReport::count`
/// or `pub fn Map[K, V]::get`.
fn associated_type_name(type_part : String) -> String {
let after_fn = if type_part.contains("pub fn ") {
match type_part.split_once("pub fn ") {
Some((_, rest)) => trim_line(owned(rest))
None => type_part
}
} else if type_part.contains("fn ") {
match type_part.split_once("fn ") {
Some((_, rest)) => trim_line(owned(rest))
None => type_part
}
} else {
type_part
}
type_name_with_generics(after_fn)
}
///|
/// Read a type name including optional `[...]` generic arguments, so
/// `Map[K, V]::get` and `Map[A, B]::get` stay distinct identities.
fn type_name_with_generics(text : String) -> String {
let base = first_identifier(text)
if base.length() == 0 {
return ""
}
let after_base = match text.split_once(base) {
Some((_, rest)) => rest
None => return base
}
if !after_base.has_prefix("[") {
return base
}
let mut depth = 0
let mut generics = ""
for char in after_base {
generics = generics + char.to_string()
if char == '[' {
depth = depth + 1
} else if char == ']' {
depth = depth - 1
if depth == 0 {
return base + generics
}
}
}
base + generics
}
///|
fn name_after(text : String, prefix : String) -> String {
match text.split_once(prefix) {
Some((_, rest)) => first_identifier(trim_line(owned(rest)))
None => ""
}
}
///|
fn name_after_keyword(signature : String, keyword : String) -> String {
name_after(declaration_line(signature), keyword)
}
///|
fn first_identifier(text : String) -> String {
let mut result = ""
for char in text {
if is_identifier_char(char) {
result = result + char.to_string()
} else {
return result
}
}
result
}
///|
fn is_identifier_char(char : Char) -> Bool {
(char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '_' ||
char == ':'
}
///|
/// Read a multi-line block declaration starting at `start` until brace
/// depth returns to zero. Returns the joined block text and the index
/// of the first line after the block.
fn read_block(lines : Array[String], start : Int) -> (String, Int) {
let mut index = start
let mut block = ""
let mut depth = 0
while index < lines.length() {
let line = lines[index]
block = append_line(block, line)
depth = depth + brace_delta(line)
index = index + 1
if depth <= 0 && line.contains("}") {
break
}
}
(block, index)
}
///|
fn trim_line(line : String) -> String {
line.trim(char_set=" \t\r").to_owned()
}
///|
/// Rank visibility so tightening can be detected by comparison:
/// `pub(all)` = 4, `pub(open)` = 3, `pub` = 2, package-private = 1.
fn visibility_level(signature : String) -> Int {
if signature.contains("pub(all)") {
4
} else if signature.contains("pub(open)") {
3
} else if signature.contains("pub ") || signature.has_prefix("pub") {
2
} else {
1
}
}
///|
fn visibility_label(signature : String) -> String {
if signature.contains("pub(all)") {
"pub(all)"
} else if signature.contains("pub(open)") {
"pub(open)"
} else if signature.contains("pub ") || signature.has_prefix("pub") {
"pub"
} else {
"package-private"
}
}
///|
fn visibility_tightened(old_signature : String, new_signature : String) -> Bool {
visibility_level(new_signature) < visibility_level(old_signature)
}
///|
fn visibility_widened(old_signature : String, new_signature : String) -> Bool {
visibility_level(new_signature) > visibility_level(old_signature)
}
///|
/// Text of the trailing `derive(...)` clause, or empty when absent.
fn derive_clause(signature : String) -> String {
let line = declaration_line(signature)
match line.split_once(" derive(") {
Some((_, after)) => {
let mut body = ""
let mut depth = 1
for char in after {
if char == '(' {
depth = depth + 1
body = body + char.to_string()
} else if char == ')' {
depth = depth - 1
if depth == 0 {
return trim_line(body)
}
body = body + char.to_string()
} else {
body = body + char.to_string()
}
}
trim_line(body)
}
None => ""
}
}
///|
fn classify_derive_change(
kind : String,
old_signature : String,
new_signature : String,
) -> (String, String) {
let old_d = derive_clause(old_signature)
let new_d = derive_clause(new_signature)
if old_d.length() == 0 && new_d.length() > 0 {
return ("derive-added", "\{kind} derive clause added: \{new_d}")
}
if old_d.length() > 0 && new_d.length() == 0 {
return ("derive-removed", "\{kind} derive clause removed")
}
(
"derive-changed",
"\{kind} derive clause changed from (\{old_d}) to (\{new_d})",
)
}