///|
/// Mini-parser for the `#derive.binary_codec(...)` attribute arguments.
///
/// The MoonBit compiler treats unknown attributes as inert, so the tool
/// parses their raw text itself (same technique as the Golem SDK tools).
///|
pub(all) enum EvolutionIR {
FieldAddedIR(String, String)
FieldMadeOptionalIR(String)
FieldRemovedIR(String)
FieldMadeTransientIR(String)
}
///|
/// True when the raw attribute text is our derive marker.
pub fn is_binary_codec_attr(raw : String) -> Bool {
raw == "#desert(binary_codec)" ||
raw.has_prefix("#desert(binary_codec(") ||
raw.has_prefix("#desert(binary_codec,")
}
///|
/// Parse the argument list body (the text between the outer parentheses).
pub fn parse_derive_args(body : String) -> DeriveOptions raise CodegenError {
let options = DeriveOptions::empty()
// The parenthesized body may start with the literal `binary_codec` token
// (form: `#derive(binary_codec, ...)`); skip it.
let trimmed = body.trim().to_owned()
let args_body = if trimmed == "binary_codec" {
""
} else if trimmed.has_prefix("binary_codec,") {
trimmed[12:].to_owned()
} else {
trimmed
}
for arg in split_top_level(args_body) {
let arg = arg.trim().to_owned()
if arg == "" {
continue
}
if arg == "transparent" {
options.transparent = true
} else if arg == "sorted_constructors" {
options.sorted_constructors = true
} else if arg.has_prefix("type_id") {
match split_kv(arg) {
Some(value) => options.type_id = Some(unquote(value))
None => raise CodegenError("type_id must be type_id = \"...\"")
}
} else if arg.has_prefix("evolution(") {
let inner = between_parens(arg)
for step in split_top_level(inner) {
options.steps.push(parse_evolution_step(step))
}
} else {
raise CodegenError("unknown derive argument: \{arg}")
}
}
options
}
///|
fn parse_evolution_step(step : String) -> EvolutionIR raise CodegenError {
let step = step.trim().to_owned()
if step.has_prefix("FieldAdded(") {
let parts = split_top_level(between_parens(step))
if parts.is_empty() {
raise CodegenError("FieldAdded requires (name, default)")
}
let name = unquote(parts[0].trim().to_owned())
let default = if parts.length() > 1 {
join_with_commas(parts, 1).trim().to_owned()
} else {
raise CodegenError("FieldAdded requires a default expression")
}
FieldAddedIR(name, default)
} else if step.has_prefix("FieldMadeOptional(") {
FieldMadeOptionalIR(unquote(between_parens(step).trim().to_owned()))
} else if step.has_prefix("FieldRemoved(") {
FieldRemovedIR(unquote(between_parens(step).trim().to_owned()))
} else if step.has_prefix("FieldMadeTransient(") {
FieldMadeTransientIR(unquote(between_parens(step).trim().to_owned()))
} else {
raise CodegenError("unknown evolution step: \{step}")
}
}
///|
/// Extract the text between the first '(' and its matching ')'.
fn between_parens(s : String) -> String raise CodegenError {
let start = index_of_char(s, '(')
let end = match matching_paren(s, start) {
Some(i) => i
None => raise CodegenError("unbalanced parentheses in: \{s}")
}
s[start + 1:end].to_owned()
}
///|
fn index_of_char(s : String, target : Char) -> Int32 {
let mut i = 0
let len = s.length()
while i < len {
match s.get_char(i) {
Some(c) => if c == target { return i }
None => ()
}
i = i + 1
}
-1
}
///|
/// Index of the ')' matching the '(' at `open_index`, honoring nesting and
/// string literals.
fn matching_paren(s : String, open_index : Int32) -> Int32? {
let mut depth = 0
let mut in_string = false
let mut i = open_index
let len = s.length()
while i < len {
let c = match s.get_char(i) {
Some(c) => c
None => break
}
if in_string {
if c == '\\' {
i = i + 1
} else if c == '"' {
in_string = false
}
} else if c == '"' {
in_string = true
} else if c == '(' {
depth = depth + 1
} else if c == ')' {
depth = depth - 1
if depth == 0 {
return Some(i)
}
}
i = i + 1
}
None
}
///|
/// Split on top-level commas (outside parentheses and string literals).
pub fn split_top_level(s : String) -> Array[String] {
let parts : Array[String] = []
let sb = StringBuilder()
let mut depth = 0
let mut in_string = false
let mut i = 0
let len = s.length()
while i < len {
let c = match s.get_char(i) {
Some(c) => c
None => break
}
if in_string {
sb.write_char(c)
if c == '\\' && i + 1 < len {
sb.write_char(
match s.get_char(i + 1) {
Some(next) => next
None => c
},
)
i = i + 1
} else if c == '"' {
in_string = false
}
} else if c == '"' {
in_string = true
sb.write_char(c)
} else if c == '(' {
depth = depth + 1
sb.write_char(c)
} else if c == ')' {
depth = depth - 1
sb.write_char(c)
} else if c == ',' && depth == 0 {
parts.push(sb.to_string())
sb.reset()
} else {
sb.write_char(c)
}
i = i + 1
}
let tail = sb.to_string()
if tail.trim() != "" {
parts.push(tail)
}
parts
}
///|
/// Split `key = value` at the first top-level '='.
fn split_kv(s : String) -> String? {
let mut in_string = false
let mut i = 0
let len = s.length()
while i < len {
let c = match s.get_char(i) {
Some(c) => c
None => break
}
if in_string {
if c == '\\' {
i = i + 1
} else if c == '"' {
in_string = false
}
} else if c == '"' {
in_string = true
} else if c == '=' {
let v = s[i + 1:].trim().to_owned()
if v != "" {
return Some(v)
}
return None
}
i = i + 1
}
None
}
///|
/// Strip surrounding double quotes.
fn unquote(s : String) -> String raise CodegenError {
let t = s.trim().to_owned()
if t.has_prefix("\"") && t.has_suffix("\"") && t.length() >= 2 {
unescape_string(t[1:t.length() - 1].to_owned())
} else {
raise CodegenError("expected a quoted string, got: \{t}")
}
}
///|
/// Process the standard escape sequences of a MoonBit string literal body.
fn unescape_string(s : String) -> String {
let sb = StringBuilder()
let len = s.length()
let mut i = 0
while i < len {
match s.get_char(i) {
Some(c) => {
if c == '\\' && i + 1 < len {
match s.get_char(i + 1) {
Some('\"') => {
sb.write_char('\"')
i = i + 1
}
Some('\\') => {
sb.write_char('\\')
i = i + 1
}
Some('n') => {
sb.write_char('\n')
i = i + 1
}
Some('t') => {
sb.write_char('\t')
i = i + 1
}
Some(next) => {
sb.write_char('\\')
sb.write_char(next)
i = i + 1
}
None => sb.write_char('\\')
}
} else {
sb.write_char(c)
}
i = i + 1
}
None => i = i + 1
}
}
sb.to_string()
}
///|
/// Join parts[from..] back with ", " (used for default expressions that
/// contain top-level commas, e.g. `FieldAdded("pair", (0, ""))` — such
/// defaults must be parenthesized by the author).
fn join_with_commas(
parts : Array[String],
from : Int32,
) -> String raise CodegenError {
if from >= parts.length() {
raise CodegenError("missing argument")
}
let sb = StringBuilder()
for i in from.. from {
sb.write_string(", ")
}
sb.write_string(parts[i])
}
sb.to_string()
}