Skip to main content

bms_table/
lib.rs

1//! BMS difficulty table parsing
2//!
3//! Provides building a complete BMS difficulty table data structure from header JSON and chart data JSON,
4//! covering the header, courses, trophies, and chart items.
5//! Also includes HTML parsing (via [`htmlparser`](https://docs.rs/htmlparser)) for extracting the bmstable
6//! header URL from a page.
7//!
8//! # Features
9//!
10//! - Parse header JSON into [`BmsTableHeader`], preserving unrecognized fields in `extra` for forward compatibility;
11//! - Parse chart data into [`BmsTableData`], supporting a plain array of [`ChartItem`] structure;
12//! - Course `md5`/`sha256` shorthand lists are preserved as independent fields; use [`CourseInfo::all_charts`] for a merged view;
13//! - Extract the header JSON URL from HTML `<meta name="bmstable">` (zero-copy, returns `&str`).
14//!
15//! # Usage
16//!
17//! ```rust
18//! # fn main() -> Result<(), serde_json::Error> {
19//! use bms_table::{BmsTable, BmsTableHeader, BmsTableData};
20//!
21//! let header_json = r#"{ "name": "Test", "symbol": "t", "data_url": "charts.json", "course": [], "level_order": [] }"#;
22//! let data_json = r#"[]"#;
23//! let header: BmsTableHeader = serde_json::from_str(header_json)?;
24//! let data: BmsTableData = serde_json::from_str(data_json)?;
25//! let table = BmsTable::new(header, data);
26//! assert!(table.header.course.flatten().is_empty());
27//! # Ok(())
28//! # }
29//! ```
30
31mod de;
32mod error;
33
34/// Re-export for convenience.
35pub use crate::error::BmsTableError;
36
37use std::collections::BTreeMap;
38use std::ops::{Deref, DerefMut};
39
40use htmlparser::{Token, Tokenizer};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43
44use crate::de::{deserialize_level, deserialize_level_order};
45
46// Data types
47
48/// Top-level BMS difficulty table data structure.
49///
50/// Packs header metadata and chart data together to simplify passing and use in applications.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[non_exhaustive]
53pub struct BmsTable {
54    /// Header information and extra fields
55    pub header: BmsTableHeader,
56    /// Table data containing the chart list
57    pub data: BmsTableData,
58}
59
60impl BmsTable {
61    /// Creates a new `BmsTable` from its header and data.
62    #[must_use]
63    pub const fn new(header: BmsTableHeader, data: BmsTableData) -> Self {
64        Self { header, data }
65    }
66}
67
68/// BMS header information.
69///
70/// Strictly parses common fields and preserves unrecognized fields in `extra` for forward compatibility.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct BmsTableHeader {
74    /// Table name, e.g. "Satellite"
75    pub name: String,
76    /// Table symbol, e.g. "sl"
77    pub symbol: String,
78    /// URL of chart data file (preserves the original string from header JSON)
79    pub data_url: String,
80    /// Tag label text; use [`effective_tag`](BmsTableHeader::effective_tag) to
81    /// get the spec-mandated fallback to `symbol` when this is `None`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub tag: Option<String>,
84    /// Play mode hint; same semantics as bmson's `mode_hint`
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub mode: Option<String>,
87    /// Course information, preserving the original JSON nesting shape.
88    ///
89    /// Supports flat arrays (`"course": [{...}]`) and arbitrarily nested arrays
90    /// (`"course": [[{...}]]`, `"course": [[{...}], [{...}]]`, etc.).
91    /// Deserialized as a [`CourseGroup`] tree.
92    #[serde(default)]
93    pub course: CourseGroup,
94    /// Difficulty level order containing numbers and strings
95    #[serde(default, deserialize_with = "deserialize_level_order")]
96    pub level_order: Vec<String>,
97    /// Extra data (unrecognized fields from header JSON)
98    #[serde(flatten)]
99    pub extra: BTreeMap<String, Value>,
100}
101
102impl BmsTableHeader {
103    /// Creates a new `BmsTableHeader` with the given required fields and defaults for everything else.
104    #[must_use]
105    pub const fn new(name: String, symbol: String, data_url: String) -> Self {
106        Self {
107            name,
108            symbol,
109            data_url,
110            tag: None,
111            mode: None,
112            course: CourseGroup::Courses(Vec::new()),
113            level_order: Vec::new(),
114            extra: BTreeMap::new(),
115        }
116    }
117
118    /// Returns the index of a level in `level_order`, or `None` if not found.
119    ///
120    /// This is useful for comparing difficulty levels: a lower index means an easier level.
121    ///
122    /// **Note:** This performs a linear scan (`O(n)`) on each call. For repeated
123    /// lookups across many chart items, consider building a
124    /// `HashMap<&str, usize>` from `level_order` once.
125    ///
126    /// # Example
127    ///
128    /// ```rust
129    /// # use bms_table::BmsTableHeader;
130    /// let mut header = BmsTableHeader::new(
131    ///     "Test".into(),
132    ///     "t".into(),
133    ///     "d.json".into(),
134    /// );
135    /// header.level_order = vec!["1".into(), "2".into(), "3".into()];
136    /// assert_eq!(header.level_index("2"), Some(1));
137    /// assert_eq!(header.level_index("4"), None);
138    /// ```
139    #[must_use]
140    pub fn level_index(&self, level: &str) -> Option<usize> {
141        self.level_order.iter().position(|l| l == level)
142    }
143
144    /// Returns the effective tag, falling back to `symbol` when `tag` is `None`.
145    ///
146    /// Per the BMS difficulty table spec, the `tag` field should fall back
147    /// to `symbol` when absent. This method implements that behavior.
148    ///
149    /// # Example
150    ///
151    /// ```rust
152    /// # use bms_table::BmsTableHeader;
153    /// let header = BmsTableHeader::new("Table".into(), "sl".into(), "d.json".into());
154    /// assert_eq!(header.effective_tag(), "sl");
155    /// ```
156    #[must_use]
157    pub fn effective_tag(&self) -> &str {
158        self.tag.as_deref().unwrap_or(&self.symbol)
159    }
160}
161
162/// BMS table data.
163///
164/// Wraps the chart array (`[...]`). The input JSON is expected to be a plain array of [`ChartItem`]
165/// objects; the `{ "charts": [...] }` wrapper form is **not** supported.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(transparent)]
168#[non_exhaustive]
169pub struct BmsTableData(pub Vec<ChartItem>);
170
171impl BmsTableData {
172    /// Creates a new `BmsTableData` with the given chart list.
173    #[must_use]
174    pub const fn new(charts: Vec<ChartItem>) -> Self {
175        Self(charts)
176    }
177}
178
179impl Deref for BmsTableData {
180    type Target = Vec<ChartItem>;
181
182    fn deref(&self) -> &Self::Target {
183        &self.0
184    }
185}
186
187impl DerefMut for BmsTableData {
188    fn deref_mut(&mut self) -> &mut Self::Target {
189        &mut self.0
190    }
191}
192
193impl Default for BmsTableData {
194    fn default() -> Self {
195        Self::new(Vec::new())
196    }
197}
198
199/// Recursive course tree supporting arbitrary nesting depth.
200///
201/// - [`Courses`][CourseGroup::Courses] — a leaf node containing a list of [`CourseInfo`] entries
202/// - [`SubGroups`][CourseGroup::SubGroups] — a branch node containing nested sub-groups
203///
204/// Together with `CourseGroup` as the `course` field type, this preserves
205/// the original JSON nesting shape round-trip:
206///
207/// | JSON | Rust |
208/// |---|---|
209/// | `"course": []` | `Courses(vec![])` |
210/// | `"course": [{...}]` | `Courses(vec![CourseInfo])` |
211/// | `"course": [[{...}]]` | `SubGroups(vec![Courses(vec![CourseInfo])])` |
212/// | `"course": [[{...}], [{...}]]` | `SubGroups(vec![Courses(..), Courses(..)])` |
213/// | `"course": [[[{...}]]]` | `SubGroups(vec![SubGroups(vec![Courses(..)])])` |
214///
215/// This enum is `#[non_exhaustive]` — use [`flatten`](CourseGroup::flatten) or
216/// [`into_flattened`](CourseGroup::into_flattened) to access all [`CourseInfo`]
217/// entries without matching on variants directly.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219#[serde(untagged)]
220#[non_exhaustive]
221pub enum CourseGroup {
222    /// A leaf node containing a list of course entries.
223    Courses(Vec<CourseInfo>),
224    /// A branch node containing nested sub-groups.
225    SubGroups(Vec<CourseGroup>),
226}
227
228impl Default for CourseGroup {
229    fn default() -> Self {
230        Self::Courses(Vec::new())
231    }
232}
233
234impl CourseGroup {
235    /// Flattens all [`CourseInfo`] references in this sub-tree.
236    #[must_use]
237    pub fn flatten(&self) -> Vec<&CourseInfo> {
238        match self {
239            Self::Courses(v) => v.iter().collect(),
240            Self::SubGroups(v) => v.iter().flat_map(CourseGroup::flatten).collect(),
241        }
242    }
243
244    /// Flattens this sub-tree into owned [`CourseInfo`] values.
245    #[must_use]
246    pub fn into_flattened(self) -> Vec<CourseInfo> {
247        match self {
248            Self::Courses(v) => v,
249            Self::SubGroups(v) => v
250                .into_iter()
251                .flat_map(CourseGroup::into_flattened)
252                .collect(),
253        }
254    }
255}
256
257impl From<CourseInfo> for CourseGroup {
258    fn from(info: CourseInfo) -> Self {
259        Self::Courses(vec![info])
260    }
261}
262
263/// Course information.
264///
265/// Describes a course's name, constraints, trophies and chart set.
266/// Charts are specified via three independent fields that can coexist:
267/// `charts` (full objects), `md5` (hash shorthand), and `sha256` (hash shorthand).
268/// All three are preserved for round-trip fidelity.
269/// Use [`CourseInfo::all_charts`] for a merged view.
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[non_exhaustive]
272pub struct CourseInfo {
273    /// Course name, e.g. "Satellite Skill Analyzer 2nd sl0"
274    pub name: String,
275    /// Constraint list, e.g. `["grade_mirror", "gauge_lr2", "ln"]`
276    #[serde(default)]
277    pub constraint: Vec<String>,
278    /// List of trophies, defining requirements for different ranks
279    #[serde(default)]
280    pub trophy: Vec<Trophy>,
281    /// Full chart objects from the `charts` JSON array
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub charts: Vec<ChartItem>,
284    /// MD5 hash shorthand list, expanded by [`all_charts`](CourseInfo::all_charts) with `level = "0"`
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    pub md5: Vec<String>,
287    /// SHA256 hash shorthand list, expanded by [`all_charts`](CourseInfo::all_charts) with `level = "0"`
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub sha256: Vec<String>,
290    /// Extra data (unrecognized fields from course JSON)
291    #[serde(flatten)]
292    pub extra: BTreeMap<String, Value>,
293}
294
295impl CourseInfo {
296    /// Creates a new `CourseInfo` with the given name and all other fields empty.
297    #[must_use]
298    pub const fn new(name: String) -> Self {
299        Self {
300            name,
301            constraint: Vec::new(),
302            trophy: Vec::new(),
303            charts: Vec::new(),
304            md5: Vec::new(),
305            sha256: Vec::new(),
306            extra: BTreeMap::new(),
307        }
308    }
309
310    /// Merges all chart sources in order: `charts` → `md5` → `sha256`.
311    ///
312    /// Hash shorthand entries (`md5`, `sha256`) are expanded into [`ChartItem`]s
313    /// with `level` defaulting to `"0"` per the BMS difficulty table spec.
314    #[must_use]
315    pub fn all_charts(&self) -> Vec<ChartItem> {
316        fn from_md5(hash: &str) -> ChartItem {
317            ChartItem {
318                md5: Some(hash.to_owned()),
319                level: "0".to_owned(),
320                ..ChartItem::empty()
321            }
322        }
323        fn from_sha256(hash: &str) -> ChartItem {
324            ChartItem {
325                sha256: Some(hash.to_owned()),
326                level: "0".to_owned(),
327                ..ChartItem::empty()
328            }
329        }
330
331        let mut result = self.charts.clone();
332        result.extend(self.md5.iter().map(|h| from_md5(h)));
333        result.extend(self.sha256.iter().map(|h| from_sha256(h)));
334        result
335    }
336}
337
338/// Chart data item.
339///
340/// Describes metadata and resource links for a single BMS file.
341///
342/// Only the most commonly used spec-defined fields (`md5`, `sha256`, `level`,
343/// `title`, `artist`, `url`, `url_diff`, `comment`) are exposed as first-class
344/// fields. Other spec-defined optional fields (such as `name_diff`, `url_pack`,
345/// `name_pack`, `org_md5`, `mode`, `ipfs`, `ipfs_diff`, `lr2_bmsid`, etc.) are
346/// **not** individually promoted — they are preserved via [`extra`](ChartItem::extra)
347/// for forward compatibility. This keeps the struct lean while remaining
348/// fully compatible with all real-world tables.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[non_exhaustive]
351pub struct ChartItem {
352    /// Difficulty level, e.g. "0"
353    ///
354    /// Defaults to `"0"` when `null` or absent in JSON, per the BMS
355    /// difficulty table spec.
356    #[serde(
357        default = "crate::de::default_level",
358        deserialize_with = "deserialize_level"
359    )]
360    pub level: String,
361    /// MD5 hash of the file
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub md5: Option<String>,
364    /// SHA256 hash of the file
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub sha256: Option<String>,
367    /// Song title
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub title: Option<String>,
370    /// Artist name
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub artist: Option<String>,
373    /// File download URL
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub url: Option<String>,
376    /// Differential file download URL (optional)
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub url_diff: Option<String>,
379    /// Comment text
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub comment: Option<String>,
382    /// Extra data (unrecognized fields)
383    #[serde(flatten)]
384    pub extra: BTreeMap<String, Value>,
385}
386
387impl ChartItem {
388    /// Creates a zero-alloc empty `ChartItem` with an empty level string.
389    ///
390    /// Unlike [`new`][Self::new] and [`default`][Default::default], this constructor
391    /// does **not** allocate — the level is an empty `String` (not `"0"`).
392    /// Prefer [`new`][Self::new] or [`default`][Default::default] when you need the
393    /// spec-mandated default level.
394    #[must_use]
395    pub const fn empty() -> Self {
396        Self {
397            level: String::new(),
398            md5: None,
399            sha256: None,
400            title: None,
401            artist: None,
402            url: None,
403            url_diff: None,
404            comment: None,
405            extra: BTreeMap::new(),
406        }
407    }
408
409    /// Creates a new `ChartItem` with the given level and all other fields set to defaults.
410    #[must_use]
411    pub const fn new(level: String) -> Self {
412        Self {
413            level,
414            md5: None,
415            sha256: None,
416            title: None,
417            artist: None,
418            url: None,
419            url_diff: None,
420            comment: None,
421            extra: BTreeMap::new(),
422        }
423    }
424}
425
426impl Default for ChartItem {
427    fn default() -> Self {
428        Self::new(crate::de::default_level())
429    }
430}
431
432/// Trophy information.
433///
434/// Defines conditions to achieve specific trophies, including maximum miss rate and minimum score rate.
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436#[non_exhaustive]
437pub struct Trophy {
438    /// Trophy name, e.g. "silvermedal" or "goldmedal"
439    pub name: String,
440    /// Maximum miss rate (percent), e.g. 5.0 means at most 5% miss rate
441    pub missrate: f64,
442    /// Minimum score rate (percent), e.g. 70.0 means at least 70% score rate
443    pub scorerate: f64,
444}
445
446impl Trophy {
447    /// Creates a new `Trophy` with the given name and rate thresholds.
448    #[must_use]
449    pub const fn new(name: String, missrate: f64, scorerate: f64) -> Self {
450        Self {
451            name,
452            missrate,
453            scorerate,
454        }
455    }
456}
457
458/// BMS difficulty table list item.
459///
460/// Represents the basic information of a difficulty table in a list. `name`, `symbol`, and `url` are
461/// the core fields; other fields such as `tag1`, `tag2`, `comment`, `date`, `state`, and `tag_order`
462/// are collected into [`extra`](BmsTableInfo::extra).
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[non_exhaustive]
465pub struct BmsTableInfo {
466    /// Table name, e.g. ".WAS Difficulty Table"
467    pub name: String,
468    /// Table symbol, e.g. "." or "\[F\]"
469    pub symbol: String,
470    /// Table URL
471    pub url: url::Url,
472    /// Extra fields collection (stores all data except required fields)
473    #[serde(flatten)]
474    pub extra: BTreeMap<String, Value>,
475}
476
477impl BmsTableInfo {
478    /// Creates a new `BmsTableInfo` with the given required fields and an empty `extra`.
479    #[must_use]
480    pub const fn new(name: String, symbol: String, url: url::Url) -> Self {
481        Self {
482            name,
483            symbol,
484            url,
485            extra: BTreeMap::new(),
486        }
487    }
488}
489
490/// Wrapper type for the list of BMS difficulty tables.
491///
492/// Transparently serialized as an array: serialization/deserialization behaves the same as the internal `Vec<BmsTableInfo>`, resulting in a JSON array rather than an object.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(transparent)]
495#[non_exhaustive]
496pub struct BmsTableList(pub Vec<BmsTableInfo>);
497
498impl BmsTableList {
499    /// Creates a new `BmsTableList` with the given entries.
500    #[must_use]
501    pub const fn new(entries: Vec<BmsTableInfo>) -> Self {
502        Self(entries)
503    }
504}
505
506impl Deref for BmsTableList {
507    type Target = Vec<BmsTableInfo>;
508
509    fn deref(&self) -> &Self::Target {
510        &self.0
511    }
512}
513
514impl DerefMut for BmsTableList {
515    fn deref_mut(&mut self) -> &mut Self::Target {
516        &mut self.0
517    }
518}
519
520// HTML parsing helper
521
522/// HTML parsing for BMS difficulty tables.
523///
524/// Provides extraction of the header JSON URL from
525/// `<meta name="bmstable" content="...">` or
526/// `<meta property="bmstable" content="...">` in HTML page content.
527///
528/// The implementation uses the `htmlparser` tokenizer under the hood,
529/// returning a **borrowed** slice of the original input where possible.
530/// Attribute values containing HTML entities are decoded into the tokenizer's
531/// internal buffer (heap-allocated), which borrows the input string, so the
532/// returned `&str` lifetime remains tied to the original input.
533/// Tag and attribute names are matched case-insensitively.
534/// Content inside HTML comments is naturally ignored by the tokenizer.
535pub struct BmsTableHtml;
536
537impl BmsTableHtml {
538    /// Extract the header JSON URL from `<meta name="bmstable" content="...">` in HTML.
539    ///
540    /// Returns a borrowed `&str` referencing the original input — no allocation.
541    /// Scans `<meta>` tags looking for elements with `name="bmstable"` or
542    /// `property="bmstable"` and reads their `content` attribute.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`BmsTableError::MetaTagNotFound`] when the target tag is not
547    /// found or `content` is empty.
548    ///
549    /// # Example
550    ///
551    /// ```rust
552    /// # use bms_table::BmsTableHtml;
553    /// let html = r#"
554    /// <!DOCTYPE html>
555    /// <html>
556    ///   <head>
557    ///     <meta name="bmstable" content="header.json">
558    ///   </head>
559    ///   <body></body>
560    /// </html>
561    /// "#;
562    /// let url = BmsTableHtml::extract_url(html).unwrap();
563    /// assert_eq!(url, "header.json");
564    /// ```
565    pub fn extract_url<'a>(html_content: &'a str) -> Result<&'a str, BmsTableError> {
566        let mut in_meta = false;
567        let mut is_bmstable = false;
568        let mut content: Option<&'a str> = None;
569        let mut tokenizer_error: Option<String> = None;
570
571        for token in Tokenizer::from(html_content) {
572            let token = match token {
573                Ok(t) => t,
574                Err(e) => {
575                    if tokenizer_error.is_none() {
576                        tokenizer_error = Some(e.to_string());
577                    }
578                    continue;
579                }
580            };
581            match token {
582                Token::ElementStart { local, .. } => {
583                    in_meta = local.as_str().eq_ignore_ascii_case("meta");
584                    if in_meta {
585                        is_bmstable = false;
586                        content = None;
587                    }
588                }
589                Token::Attribute { local, value, .. } if in_meta => {
590                    let name = local.as_str();
591                    if name.eq_ignore_ascii_case("name") || name.eq_ignore_ascii_case("property") {
592                        is_bmstable =
593                            value.is_some_and(|v| v.as_str().eq_ignore_ascii_case("bmstable"));
594                    } else if name.eq_ignore_ascii_case("content") {
595                        content = value.map(|v| v.as_str());
596                    }
597                }
598                Token::ElementEnd { .. } if in_meta => {
599                    if is_bmstable
600                        && let Some(c) = content
601                        && !c.is_empty()
602                    {
603                        return Ok(c);
604                    }
605                    in_meta = false;
606                }
607                _ => {}
608            }
609        }
610
611        Err(tokenizer_error.map_or(
612            BmsTableError::MetaTagNotFound,
613            BmsTableError::TokenizerError,
614        ))
615    }
616}