Files
harmony/opnsense-codegen/src/parser.rs
2026-05-20 12:03:19 -04:00

1206 lines
37 KiB
Rust

// `XmlNode` currently has only one used variant (`Element`); the
// `let XmlNode::Element { ... } = node else { ... }` pattern is
// therefore irrefutable today, but the `else` arm is kept
// deliberately as defense-in-depth — when the IR grows a second
// variant (Text, Comment, …) the compiler will start enforcing it
// again. Suppress the warning at the file level so the structure
// of these defensive guards stays uniform across the parser.
#![allow(irrefutable_let_patterns)]
use heck::{ToPascalCase, ToSnakeCase};
use log::info;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::collections::HashMap;
use std::io::Cursor;
use crate::ir::{EnumIR, EnumVariantIR, FieldIR, ModelIR, StructIR, StructKind};
/// Ensure a string is a valid Rust identifier by prefixing digit-starting names
/// with `V` (e.g., `0NoClientHello` → `V0NoClientHello`, `8021q` → `V8021q`).
fn sanitize_rust_ident(name: &str) -> String {
if name.starts_with(|c: char| c.is_ascii_digit()) {
format!("V{name}")
} else {
name.to_string()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("XML error: {0}")]
Xml(String),
#[error("Parse error: {0}")]
Custom(String),
}
#[derive(Debug, Clone)]
enum XmlNode {
Element {
name: String,
attributes: HashMap<String, String>,
children: Vec<XmlNode>,
text: Option<String>,
},
}
fn parse_xml_into_tree(xml_data: &[u8]) -> Result<XmlNode, ParseError> {
let mut reader = Reader::from_reader(Cursor::new(xml_data.to_vec()));
let mut buf = Vec::new();
let mut stack: Vec<XmlNode> = Vec::new();
let mut root: Option<XmlNode> = None;
loop {
buf.clear();
let event = reader
.read_event_into(&mut buf)
.map_err(|e| ParseError::Xml(e.to_string()))?;
match event {
Event::Start(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let attributes: HashMap<String, String> = e
.attributes()
.flatten()
.map(|a| {
(
String::from_utf8_lossy(a.key.as_ref()).to_string(),
String::from_utf8_lossy(a.value.as_ref()).to_string(),
)
})
.collect();
stack.push(XmlNode::Element {
name,
attributes,
children: Vec::new(),
text: None,
});
}
Event::Text(e) => {
let text = e.unescape().map_err(|e| ParseError::Xml(e.to_string()))?;
if !text.trim().is_empty()
&& let Some(XmlNode::Element { text: cur_text, .. }) = stack.last_mut()
&& cur_text.is_none()
{
*cur_text = Some(text.to_string());
}
}
Event::End(_) => {
if let Some(node) = stack.pop() {
let elem = match node {
XmlNode::Element {
name,
attributes,
children,
text,
} => XmlNode::Element {
name,
attributes,
children,
text,
},
};
if stack.is_empty() {
root = Some(elem);
} else if let Some(XmlNode::Element { children: sib, .. }) = stack.last_mut() {
sib.push(elem);
}
}
}
Event::Empty(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let attributes: HashMap<String, String> = e
.attributes()
.flatten()
.map(|a| {
(
String::from_utf8_lossy(a.key.as_ref()).to_string(),
String::from_utf8_lossy(a.value.as_ref()).to_string(),
)
})
.collect();
let elem = XmlNode::Element {
name,
attributes,
children: Vec::new(),
text: None,
};
if stack.is_empty() {
root = Some(elem);
} else if let Some(XmlNode::Element { children: sib, .. }) = stack.last_mut() {
sib.push(elem);
}
}
Event::Eof => break,
_ => {}
}
}
root.ok_or_else(|| ParseError::Custom("no root element".to_string()))
}
pub fn parse_xml(xml_data: &[u8]) -> Result<ModelIR, ParseError> {
let root = parse_xml_into_tree(xml_data)?;
let XmlNode::Element {
name: root_name,
children,
..
} = root
else {
return Err(ParseError::Custom("root must be an element".to_string()));
};
if root_name != "model" {
return Err(ParseError::Custom(format!(
"expected <model> root, got <{}>",
root_name
)));
}
let mut model = ModelIR {
mount: String::new(),
description: String::new(),
version: String::new(),
api_key: String::new(),
root_struct_name: String::new(),
enums: Vec::new(),
structs: Vec::new(),
};
let mut items_children: Vec<XmlNode> = Vec::new();
for child in &children {
let XmlNode::Element {
name,
children: gc,
text,
..
} = child
else {
continue;
};
match name.as_str() {
"mount" => model.mount = text.clone().unwrap_or_default(),
"description" => model.description = text.clone().unwrap_or_default(),
"version" => model.version = text.clone().unwrap_or_default(),
"items" => items_children = gc.clone(),
_ => {}
}
}
model.root_struct_name = derive_root_struct_name(&model.mount);
model.api_key = derive_api_key(&model.mount);
if !items_children.is_empty() {
process_items(&items_children, &mut model)?;
}
Ok(model)
}
fn process_items(children: &[XmlNode], model: &mut ModelIR) -> Result<(), ParseError> {
let root_name = model.root_struct_name.clone();
let mut root_struct = StructIR {
name: root_name.clone(),
kind: StructKind::Root,
json_key: None,
fields: Vec::new(),
};
for child in children {
let XmlNode::Element {
name,
attributes,
children: gc,
text,
..
} = child
else {
continue;
};
if attributes.contains_key("type") {
let field = build_field(name, attributes, gc, text, root_name.clone(), model, None)?;
root_struct.fields.push(field);
} else {
let container_name = name.clone();
let child_struct = process_container(
name,
gc,
format!("{}{}", root_name, container_name.to_pascal_case()),
container_name.clone(),
model,
)?;
root_struct.fields.push(FieldIR {
name: container_name,
rust_type: child_struct.name.clone(),
serde_with: None,
opn_type: "Container".to_string(),
required: true,
default: None,
doc: None,
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(child_struct.name.clone()),
field_kind: Some("container".to_string()),
constraints: None,
relation: None,
});
model.structs.push(child_struct);
}
}
model.structs.insert(0, root_struct);
Ok(())
}
fn process_container(
_name: &str,
children: &[XmlNode],
struct_name: String,
json_key: String,
model: &mut ModelIR,
) -> Result<StructIR, ParseError> {
let mut struct_ir = StructIR {
name: struct_name,
kind: StructKind::Container,
json_key: Some(json_key),
fields: Vec::new(),
};
for child in children {
let XmlNode::Element {
name: cn,
attributes,
children: cc,
text,
..
} = child
else {
continue;
};
if attributes.contains_key("type") {
let f = build_field(
cn,
attributes,
cc,
text,
struct_ir.name.clone(),
model,
None,
)?;
struct_ir.fields.push(f);
} else {
let sub_name = cn.clone();
let child_struct = process_container(
cn,
cc,
format!("{}{}", struct_ir.name, cn.to_pascal_case()),
cn.clone(),
model,
)?;
struct_ir.fields.push(FieldIR {
name: sub_name,
rust_type: child_struct.name.clone(),
serde_with: None,
opn_type: "Container".to_string(),
required: true,
default: None,
doc: None,
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(child_struct.name.clone()),
field_kind: Some("container".to_string()),
constraints: None,
relation: None,
});
model.structs.push(child_struct);
}
}
Ok(struct_ir)
}
#[derive(Debug, Clone, Default)]
struct FieldMetadata {
required: bool,
default: Option<String>,
min: Option<i64>,
max: Option<i64>,
as_list: bool,
multiple: bool,
mask: Option<String>,
constraints: Vec<crate::ir::ConstraintIR>,
relation: Option<crate::ir::RelationIR>,
}
fn extract_field_metadata(children: &[XmlNode]) -> FieldMetadata {
let mut meta = FieldMetadata::default();
for child in children {
let XmlNode::Element {
name,
children: gc,
text,
..
} = child
else {
continue;
};
match name.as_str() {
"Required" if text.as_deref() == Some("Y") => {
meta.required = true;
}
"Default" => {
meta.default = text.clone();
}
"MinimumValue" => {
meta.min = text.as_ref().and_then(|v| v.parse().ok());
}
"MaximumValue" => {
meta.max = text.as_ref().and_then(|v| v.parse().ok());
}
"AsList" if text.as_deref() == Some("Y") => {
meta.as_list = true;
}
"Multiple" if text.as_deref() == Some("Y") => {
meta.multiple = true;
}
"Mask" => {
meta.mask = text.clone();
}
"Constraints" => {
for constraint_node in gc {
let XmlNode::Element { children: cc, .. } = constraint_node else {
continue;
};
let mut constraint_type = None;
let mut message = String::new();
for inner in cc {
let XmlNode::Element {
name: iname,
text: itext,
..
} = inner
else {
continue;
};
match iname.as_str() {
"type" => constraint_type = itext.clone(),
"ValidationMessage" => message = itext.clone().unwrap_or_default(),
_ => {}
}
}
if let Some(ct) = constraint_type {
meta.constraints.push(crate::ir::ConstraintIR {
constraint_type: ct,
message,
});
}
}
}
"Model" => {
let mut source = None;
let mut items = None;
let mut display = None;
for model_child in gc {
let XmlNode::Element {
name: _mn,
children: mcc,
..
} = model_child
else {
continue;
};
for tag_info in mcc {
let XmlNode::Element {
name: tn,
children: tcc,
..
} = tag_info
else {
continue;
};
if tn == "tag" {
for attr in tcc {
let XmlNode::Element {
name: an,
text: atext,
..
} = attr
else {
continue;
};
match an.as_str() {
"source" => source = atext.clone(),
"items" => items = atext.clone(),
"display" => display = atext.clone(),
_ => {}
}
}
}
}
}
if let (Some(source), Some(items), Some(display)) = (source, items, display) {
meta.relation = Some(crate::ir::RelationIR {
source,
items,
display,
});
}
}
_ => {}
}
}
meta
}
fn build_field(
name: &str,
attributes: &HashMap<String, String>,
children: &[XmlNode],
_text: &Option<String>,
parent_name: String,
model: &mut ModelIR,
enum_name_prefix: Option<&str>,
) -> Result<FieldIR, ParseError> {
let field_type = attributes
.get("type")
.cloned()
.unwrap_or_else(|| "TextField".to_string());
// Strip ".\" prefix used in OPNsense XML for local type references
let field_type = field_type.trim_start_matches(".\\").to_string();
let meta = extract_field_metadata(children);
if field_type == "ArrayField" {
let singular_name = singularize(name);
let item_struct_name = format!("{}{}", parent_name, singular_name.to_pascal_case());
let rust_type = format!("HashMap<String, {}>", item_struct_name);
let mut item_struct = StructIR {
name: item_struct_name.clone(),
kind: StructKind::ArrayItem,
json_key: Some(name.to_string()),
fields: Vec::new(),
};
let enum_prefix = singular_name.to_pascal_case();
for child in children {
let XmlNode::Element {
name: cn,
attributes: ca,
children: cc,
text: ct,
..
} = child
else {
continue;
};
if ca.contains_key("type") {
let f = build_field(
cn,
ca,
cc,
ct,
item_struct_name.clone(),
model,
Some(&enum_prefix),
)?;
item_struct.fields.push(f);
} else if !cc.is_empty() {
// Container element (e.g., <source>, <destination>) — generate nested struct
let container_struct = process_container(
cn,
cc,
format!("{}{}", item_struct_name, cn.to_pascal_case()),
cn.clone(),
model,
)?;
item_struct.fields.push(FieldIR {
name: cn.clone(),
rust_type: container_struct.name.clone(),
serde_with: None,
opn_type: "Container".to_string(),
required: false,
default: None,
doc: None,
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(container_struct.name.clone()),
field_kind: Some("container".to_string()),
constraints: None,
relation: None,
});
model.structs.push(container_struct);
}
}
model.structs.push(item_struct);
return Ok(FieldIR {
name: name.to_string(),
rust_type,
serde_with: None,
opn_type: field_type,
required: false,
default: None,
doc: None,
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(item_struct_name),
field_kind: Some("array_field".to_string()),
constraints: None,
relation: None,
});
}
// Custom *Field types (e.g. FilterRuleField, SourceNatRuleField) that have
// child elements with type attributes are ArrayField subclasses. Treat them
// the same as ArrayField — recursively parse children into struct fields.
let has_typed_children = children.iter().any(
|c| matches!(c, XmlNode::Element { attributes, .. } if attributes.contains_key("type")),
);
if field_type != "ArrayField"
&& field_type != "OptionField"
&& field_type.ends_with("Field")
&& has_typed_children
{
let singular_name = singularize(name);
let item_struct_name = format!("{}{}", parent_name, singular_name.to_pascal_case());
let rust_type = format!("HashMap<String, {}>", item_struct_name);
let mut item_struct = StructIR {
name: item_struct_name.clone(),
kind: StructKind::ArrayItem,
json_key: Some(name.to_string()),
fields: Vec::new(),
};
let enum_prefix = item_struct_name.clone();
for child in children {
let XmlNode::Element {
name: cn,
attributes: ca,
children: cc,
text: ct,
..
} = child
else {
continue;
};
if ca.contains_key("type") {
let f = build_field(
cn,
ca,
cc,
ct,
item_struct_name.clone(),
model,
Some(&enum_prefix),
)?;
item_struct.fields.push(f);
} else if !cc.is_empty() {
// Container element — generate nested struct
let container_struct = process_container(
cn,
cc,
format!("{}{}", item_struct_name, cn.to_pascal_case()),
cn.clone(),
model,
)?;
item_struct.fields.push(FieldIR {
name: cn.clone(),
rust_type: container_struct.name.clone(),
serde_with: None,
opn_type: "Container".to_string(),
required: false,
default: None,
doc: None,
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(container_struct.name.clone()),
field_kind: Some("container".to_string()),
constraints: None,
relation: None,
});
model.structs.push(container_struct);
}
}
model.structs.push(item_struct);
return Ok(FieldIR {
name: name.to_string(),
rust_type,
serde_with: None,
opn_type: field_type,
required: false,
default: None,
doc: Some(format!("{} (custom ArrayField subclass)", name)),
min: None,
max: None,
enum_ref: None,
as_list: None,
multiple: None,
mask: None,
struct_ref: Some(item_struct_name),
field_kind: Some("array_field".to_string()),
constraints: None,
relation: None,
});
}
if field_type == "OptionField" {
let enum_name = if let Some(prefix) = enum_name_prefix {
format!("{}{}", prefix, name.to_pascal_case())
} else {
name.to_pascal_case()
};
let mut variants = Vec::new();
let _default_variant: Option<String> = meta.default.clone();
for child in children {
let XmlNode::Element {
name: cn,
children: cc,
..
} = child
else {
continue;
};
if cn == "OptionValues" {
for variant_node in cc {
let XmlNode::Element {
name: vn,
text: vt,
attributes: va,
..
} = variant_node
else {
continue;
};
// Use the `value` attribute if present (e.g. <pcp0 value="0">),
// otherwise fall back to the element name.
let wire_value = va.get("value").cloned().unwrap_or_else(|| vn.clone());
let rust_name = vt
.as_ref()
.filter(|t| t != &vn)
.map(|t| t.to_pascal_case())
.unwrap_or_else(|| vn.to_pascal_case());
let rust_name = sanitize_rust_ident(&rust_name);
variants.push(EnumVariantIR {
rust_name,
wire_value,
});
}
}
}
if variants.is_empty()
&& let Some(t) = meta.default.clone()
&& !t.is_empty()
{
variants.push(EnumVariantIR {
rust_name: sanitize_rust_ident(&t.to_pascal_case()),
wire_value: t.clone(),
});
}
model.enums.push(EnumIR {
name: enum_name.clone(),
variants,
});
let doc = build_field_doc(
&field_type,
meta.required,
meta.default.clone(),
meta.min,
meta.max,
Some(enum_name.as_str()),
);
return Ok(FieldIR {
name: name.to_string(),
rust_type: format!("Option<{}>", enum_name),
serde_with: Some(format!("serde_{}", enum_name.to_snake_case())),
opn_type: field_type,
required: meta.required,
default: meta.default,
doc: Some(doc),
min: meta.min,
max: meta.max,
enum_ref: Some(enum_name),
as_list: None,
multiple: None,
mask: meta.mask,
struct_ref: None,
field_kind: None,
constraints: if meta.constraints.is_empty() {
None
} else {
Some(meta.constraints)
},
relation: meta.relation,
});
}
let rust_type = compute_rust_type(
&field_type,
meta.required,
meta.as_list,
meta.multiple,
meta.min,
meta.max,
);
let serde_with = derive_serde_with(
&field_type,
&rust_type,
meta.required,
meta.as_list,
meta.multiple,
);
let doc = build_field_doc(
&field_type,
meta.required,
meta.default.clone(),
meta.min,
meta.max,
None,
);
Ok(FieldIR {
name: name.to_string(),
rust_type,
serde_with,
opn_type: field_type,
required: meta.required,
default: meta.default,
doc: Some(doc),
min: meta.min,
max: meta.max,
enum_ref: None,
as_list: if meta.as_list { Some(true) } else { None },
multiple: if meta.multiple { Some(true) } else { None },
mask: meta.mask,
struct_ref: None,
field_kind: None,
constraints: if meta.constraints.is_empty() {
None
} else {
Some(meta.constraints)
},
relation: meta.relation,
})
}
fn compute_rust_type(
opn_type: &str,
required: bool,
as_list: bool,
multiple: bool,
min: Option<i64>,
max: Option<i64>,
) -> String {
match opn_type {
"BooleanField" => {
if required {
"bool".to_string()
} else {
"Option<bool>".to_string()
}
}
"IntegerField" | "AutoNumberField" => {
let use_u32 =
max.map(|m| m > 65535).unwrap_or(false) || min.is_none() || min == Some(0);
if use_u32 {
"Option<u32>".to_string()
} else {
"Option<u16>".to_string()
}
}
"NumericField" => "Option<String>".to_string(),
"TextField"
| "DescriptionField"
| "UpdateOnlyTextField"
| "Base64Field"
| "UniqueIdField"
| "HostnameField"
| "NetworkField"
| "EmailField"
| "UrlField"
| "MacAddressField"
| "IPPortField"
| "PortField"
| "NetworkAliasField"
| "VirtualIPField"
| "CertificateField"
| "AuthGroupField"
| "AuthenticationServerField"
| "CountryField"
| "ProtocolField"
| "ConfigdActionsField"
| "JsonKeyValueStoreField"
| "InterfaceField"
| "LegacyLinkField"
| "CSVListField" => {
if as_list || multiple {
"Option<Vec<String>>".to_string()
} else if required {
"String".to_string()
} else {
"Option<String>".to_string()
}
}
"OptionField" => "Option<Enum>".to_string(),
"ArrayField" => "HashMap<String, Item>".to_string(),
"ModelRelationField" => {
if multiple {
"Option<Vec<String>>".to_string()
} else {
"Option<String>".to_string()
}
}
other => {
// Unknown *Field types may return select widget objects from the
// API, so they go through opn_string/opn_csv which always return
// Option types.
if other.ends_with("Field") {
if as_list || multiple {
"Option<Vec<String>>".to_string()
} else {
"Option<String>".to_string()
}
} else if as_list || multiple {
"Option<Vec<String>>".to_string()
} else if required {
"String".to_string()
} else {
"Option<String>".to_string()
}
}
}
}
fn derive_serde_with(
opn_type: &str,
rust_type: &str,
required: bool,
as_list: bool,
multiple: bool,
) -> Option<String> {
let uses_option = rust_type.starts_with("Option<") || rust_type.starts_with("HashMap");
if !(uses_option || required && opn_type == "BooleanField") {
return None;
}
match opn_type {
"BooleanField" => {
if required {
Some("opn_bool_req".to_string())
} else {
Some("opn_bool".to_string())
}
}
"IntegerField" | "AutoNumberField" => {
if rust_type.contains("u16") {
Some("opn_u16".to_string())
} else {
Some("opn_u32".to_string())
}
}
"TextField"
| "DescriptionField"
| "UpdateOnlyTextField"
| "Base64Field"
| "UniqueIdField"
| "HostnameField"
| "NetworkField"
| "EmailField"
| "UrlField"
| "MacAddressField"
| "IPPortField"
| "PortField"
| "NetworkAliasField"
| "VirtualIPField"
| "CertificateField"
| "AuthGroupField"
| "AuthenticationServerField"
| "CountryField"
| "ProtocolField"
| "ConfigdActionsField"
| "JsonKeyValueStoreField"
| "InterfaceField"
| "LegacyLinkField"
| "NumericField"
| "CSVListField"
| "ModelRelationField" => {
if as_list || multiple {
Some("opn_csv".to_string())
} else {
Some("opn_string".to_string())
}
}
other => {
// Any unrecognized *Field type might return a select widget
// object from the API, so default to opn_string/opn_csv.
if other.ends_with("Field") {
info!("Unknown field type {opn_type}, defaulting to opn_string/opn_csv");
if as_list || multiple {
Some("opn_csv".to_string())
} else {
Some("opn_string".to_string())
}
} else {
info!("Did not find type for serde derive {opn_type}");
None
}
}
}
}
fn build_field_doc(
opn_type: &str,
required: bool,
default: Option<String>,
min: Option<i64>,
max: Option<i64>,
enum_name: Option<&str>,
) -> String {
let mut parts = Vec::new();
parts.push(opn_type.to_string());
if required {
parts.push("required".to_string());
} else {
parts.push("optional".to_string());
}
if let Some(d) = default {
parts.push(format!("default={}", d));
}
if let (Some(m), Some(mx)) = (min, max) {
parts.push(format!("[{}-{}]", m, mx));
} else if let Some(m) = min {
parts.push(format!("[{}, ∞)", m));
}
if let Some(en) = enum_name {
parts.push(format!("enum={}", en));
}
parts.join(" | ")
}
fn derive_root_struct_name(mount: &str) -> String {
let parts: Vec<&str> = mount
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if parts.len() >= 2 {
parts[parts.len() - 2..]
.iter()
.map(|s| (*s).to_pascal_case())
.collect::<Vec<_>>()
.join("")
} else if let Some(last) = parts.last() {
last.to_pascal_case()
} else {
"Root".to_string()
}
}
fn derive_api_key(mount: &str) -> String {
let parts: Vec<&str> = mount
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if parts.len() >= 2 {
parts[parts.len() - 2..]
.iter()
.map(|s| s.to_snake_case())
.collect::<Vec<_>>()
.join("")
} else if let Some(last) = parts.last() {
last.to_snake_case()
} else {
"root".to_string()
}
}
fn singularize(s: &str) -> String {
static EXCEPTIONS: &[(&str, &str)] = &[
("addresses", "address"),
("aliases", "alias"),
("buses", "bus"),
("bases", "base"),
("caches", "cache"),
("cases", "case"),
("classes", "class"),
("codes", "code"),
("codes", "code"),
("courses", "course"),
("databases", "database"),
("dates", "date"),
("devices", "device"),
("directories", "directory"),
("domains", "domain"),
("entries", "entry"),
("errors", "error"),
("examples", "example"),
("fields", "field"),
("files", "file"),
("filters", "filter"),
("formats", "format"),
("functions", "function"),
("games", "game"),
("gateways", "gateway"),
("generics", "generic"),
("groups", "group"),
("histories", "history"),
("hosts", "host"),
("identifiers", "identifier"),
("images", "image"),
("indexes", "index"),
("indicies", "index"),
("instances", "instance"),
("interfaces", "interface"),
("keys", "key"),
("labels", "label"),
("languages", "language"),
("levels", "level"),
("links", "link"),
("lists", "list"),
("locations", "location"),
("logs", "log"),
("maps", "map"),
("maps", "map"),
("marks", "mark"),
("members", "member"),
("messages", "message"),
("modes", "mode"),
("models", "model"),
("names", "name"),
("networks", "network"),
("nodes", "node"),
("notifications", "notification"),
("numbers", "number"),
("objects", "object"),
("options", "option"),
("orders", "order"),
("packages", "package"),
("pages", "page"),
("pairs", "pair"),
("paths", "path"),
("policies", "policy"),
("pools", "pool"),
("ports", "port"),
("prefixes", "prefix"),
("principals", "principal"),
("profiles", "profile"),
("programs", "program"),
("projects", "project"),
("protocols", "protocol"),
("proxies", "proxy"),
("queries", "query"),
("queues", "queue"),
("ranges", "range"),
("records", "record"),
("references", "reference"),
("registries", "registry"),
("relations", "relation"),
("reports", "report"),
("requests", "request"),
("resources", "resource"),
("responses", "response"),
("rules", "rule"),
("schemas", "schema"),
("sections", "section"),
("security", "security"),
("sequences", "sequence"),
("servers", "server"),
("services", "service"),
("sessions", "session"),
("settings", "setting"),
("shares", "share"),
("signatures", "signature"),
("sizes", "size"),
("sources", "source"),
("spaces", "space"),
("specifications", "specification"),
("states", "state"),
("statistics", "statistic"),
("statuses", "status"),
("structures", "structure"),
("subnets", "subnet"),
("supports", "support"),
("symbols", "symbol"),
("syntaxes", "syntax"),
("system", "system"),
("systems", "system"),
("tables", "table"),
("tags", "tag"),
("targets", "target"),
("tasks", "task"),
("templates", "template"),
("tests", "test"),
("times", "time"),
("tokens", "token"),
("topics", "topic"),
("traces", "trace"),
("types", "type"),
("users", "user"),
("usages", "usage"),
("values", "value"),
("versions", "version"),
("views", "view"),
("volumes", "volume"),
("warnings", "warning"),
("zones", "zone"),
];
for (plural, singular) in EXCEPTIONS {
if *plural == s {
return singular.to_string();
}
}
if s.ends_with("ies") && s.len() > 3 {
format!("{}y", &s[..s.len() - 3])
} else if s.ends_with("es") && s.len() > 2 {
s[..s.len() - 2].to_string()
} else if s.ends_with("s") && s.len() > 1 {
s[..s.len() - 1].to_string()
} else {
s.to_string()
}
}