///|
/// 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
\n

moon_api_guard

\n

API compatibility report

\n

\{html_escape(self.semver_advice())}. Use the filters below to isolate breaking changes for review.

\n
\n
\n
\n
breaking\{self.breaking_count()}
\n
compatible\{self.compatible_count()}
\n
semver\{html_escape(self.semver_suggestion())}
\n
blocked\{self.release_blocked().to_string()}
\n
\n
\n\n\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
Generated by moon_api_guard · interactive filters run entirely in-browser
\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" } let mut out = "" for scope in scopes { out = out + "

\{html_escape(scope)}

\n\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" } 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})", ) }