Skip to main content

workshop_rs/analysis/
element_count.rs

1//! Canonical Workshop element-count analysis.
2//!
3//! The calculator operates on the canonical public program, not source-language syntax or
4//! emitted text. Every occurrence of a component costs its base amount at any
5//! nesting depth, direct action and condition arguments cost one less, and
6//! every pair of hero literals in one direct argument adds one. The base costs
7//! and the block-closing rules are documented, with their evidence, in
8//! `docs/element-count.md`.
9
10use std::collections::HashMap;
11use std::fmt;
12
13use crate::catalog::{Catalog, Kind};
14use crate::core::source::Span;
15use crate::wir::{self, Action, ActionId, Program, Value, ValueId};
16
17/// The Workshop node category represented in an element-count report.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ElementNodeKind {
20    Rule,
21    Condition,
22    Action,
23    Value,
24}
25
26/// One node's contribution and its nested element-count analysis.
27#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub struct ElementCountNode {
30    pub kind: ElementNodeKind,
31    /// An opaque identity unique within this report. It is not a WIR or
32    /// storage arena index and has no meaning across reports.
33    pub id: usize,
34    /// The canonical Workshop or analysis name for this node.
35    pub name: String,
36    /// The authored source span, when the program retained one.
37    pub span: Option<Span>,
38    /// The node-local contribution before child counts and adjustments.
39    pub base_count: usize,
40    /// The signed node-local adjustment, such as a direct-argument reduction
41    /// or hero-pair surcharge.
42    pub adjustment: isize,
43    /// The node's recursive count: `base_count + adjustment + children`.
44    pub count: usize,
45    /// Nested values, conditions, and actions in canonical source order.
46    pub children: Vec<ElementCountNode>,
47}
48
49/// A structured element-count report for one canonical Workshop program.
50#[derive(Debug, Clone)]
51#[non_exhaustive]
52pub struct ElementCountReport {
53    /// The sum of all rule counts.
54    pub total: usize,
55    /// Rule nodes in canonical source/WIR order.
56    pub rules: Vec<ElementCountNode>,
57}
58
59impl ElementCountReport {
60    /// Return the per-rule total in source/WIR order.
61    pub fn rule_counts(&self) -> impl Iterator<Item = (&str, usize)> {
62        self.rules
63            .iter()
64            .map(|rule| (rule.name.as_str(), rule.count))
65    }
66}
67
68/// A construct for which an exact canonical element count cannot be produced.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum ElementCountError {
72    /// The input cannot be materialized or structurally validated as a
73    /// canonical Workshop program.
74    InvalidProgram { message: String },
75    /// The input contains a construct for which this analyzer has no exact
76    /// canonical count.
77    Unsupported {
78        kind: ElementNodeKind,
79        name: String,
80        span: Option<Span>,
81        reason: String,
82    },
83    /// The internal graph contains a recursive value or action reference.
84    Cycle {
85        kind: ElementNodeKind,
86        /// The opaque identity of the active node involved in the cycle.
87        id: usize,
88    },
89}
90
91impl fmt::Display for ElementCountError {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::InvalidProgram { message } => write!(formatter, "invalid program: {message}"),
95            Self::Unsupported {
96                kind,
97                name,
98                span,
99                reason,
100            } => write!(
101                formatter,
102                "unsupported {kind:?} '{name}'{}: {reason}",
103                span.map_or_else(String::new, |span| format!(" at {span:?}"))
104            ),
105            Self::Cycle { kind, id } => write!(formatter, "cyclic {kind:?} reference at {id}"),
106        }
107    }
108}
109
110impl std::error::Error for ElementCountError {}
111
112impl Program {
113    /// Count the canonical Workshop target represented by this WIR program.
114    ///
115    /// The catalog is used to reject unknown action/value identities before a
116    /// report is produced. Native display actions are represented by their
117    /// canonical catalog-backed action calls.
118    pub(crate) fn element_count(
119        &self,
120        catalog: &Catalog,
121    ) -> Result<ElementCountReport, ElementCountError> {
122        self.validate()
123            .map_err(|error| ElementCountError::InvalidProgram {
124                message: error.to_string(),
125            })?;
126        crate::rules::validate::validate_wir(self, catalog).map_err(|error| {
127            ElementCountError::InvalidProgram {
128                message: error.to_string(),
129            }
130        })?;
131
132        let mut counter = Counter {
133            program: self,
134            catalog,
135            values: HashMap::new(),
136            actions: HashMap::new(),
137            next_node_id: 0,
138            name_slot: false,
139        };
140        let mut rules = Vec::with_capacity(self.rules.len());
141        for rule in self.rules.iter() {
142            rules.push(counter.rule(rule)?);
143        }
144        let total = rules.iter().map(|rule| rule.count).sum();
145        Ok(ElementCountReport { total, rules })
146    }
147}
148
149impl crate::program::Program {
150    /// Count the canonical Workshop target represented by this program.
151    pub fn element_count(
152        &self,
153        catalog: &Catalog,
154    ) -> Result<ElementCountReport, ElementCountError> {
155        let storage = self
156            .to_wir()
157            .map_err(|error| ElementCountError::InvalidProgram {
158                message: error.to_string(),
159            })?;
160        storage.element_count(catalog)
161    }
162}
163
164struct Counted {
165    node: ElementCountNode,
166    heroes: usize,
167}
168
169impl Counted {
170    #[allow(clippy::too_many_arguments)]
171    fn finish(
172        kind: ElementNodeKind,
173        id: usize,
174        name: impl Into<String>,
175        span: Option<Span>,
176        base_count: usize,
177        adjustment: isize,
178        children: Vec<ElementCountNode>,
179        heroes: usize,
180    ) -> Self {
181        let children_count: usize = children.iter().map(|child| child.count).sum();
182        let count = (base_count as isize + children_count as isize + adjustment).max(0) as usize;
183        Self {
184            node: ElementCountNode {
185                kind,
186                id,
187                name: name.into(),
188                span,
189                base_count,
190                adjustment,
191                count,
192                children,
193            },
194            heroes,
195        }
196    }
197}
198
199struct Counter<'a> {
200    program: &'a Program,
201    catalog: &'a Catalog,
202    values: HashMap<usize, usize>,
203    actions: HashMap<usize, usize>,
204    next_node_id: usize,
205    /// Set while counting an argument that names a variable rather than reads it.
206    name_slot: bool,
207}
208
209impl Counter<'_> {
210    fn next_node_id(&mut self) -> usize {
211        let id = self.next_node_id;
212        self.next_node_id += 1;
213        id
214    }
215
216    fn rule(&mut self, rule: &wir::Rule) -> Result<ElementCountNode, ElementCountError> {
217        let node_id = self.next_node_id();
218        let mut children = Vec::with_capacity(rule.conditions.len() + rule.actions.len());
219        for condition in &rule.conditions {
220            children.push(self.condition(condition.value)?.node);
221        }
222        for (index, action) in rule.actions.iter().enumerate() {
223            let rule_final = index + 1 == rule.actions.len();
224            children.push(self.action(*action, rule_final)?.node);
225        }
226        Ok(Counted::finish(
227            ElementNodeKind::Rule,
228            node_id,
229            &rule.name,
230            rule.span,
231            1,
232            0,
233            children,
234            0,
235        )
236        .node)
237    }
238
239    fn condition(&mut self, id: ValueId) -> Result<Counted, ElementCountError> {
240        let node_id = self.next_node_id();
241        let Some(value) = self.program.values.get(id) else {
242            return Err(ElementCountError::InvalidProgram {
243                message: format!("dangling condition value {}", id.index()),
244            });
245        };
246        let (children, heroes) = match &value.value {
247            Value::Call { name, args } if is_comparison(name) => {
248                let mut children = Vec::with_capacity(args.len());
249                let mut heroes = 0;
250                for argument in args {
251                    let counted = self.value(*argument, true)?;
252                    heroes += counted.heroes / 2 * 2;
253                    children.push(counted.node);
254                }
255                (children, heroes)
256            }
257            _ => {
258                let counted = self.value(id, true)?;
259                (vec![counted.node], counted.heroes)
260            }
261        };
262        Ok(Counted::finish(
263            ElementNodeKind::Condition,
264            node_id,
265            "condition",
266            value.span,
267            1,
268            pair_surcharge(heroes),
269            children,
270            heroes,
271        ))
272    }
273
274    fn action(&mut self, id: ActionId, rule_final: bool) -> Result<Counted, ElementCountError> {
275        let node_id = self.next_node_id();
276        if let Some(&active_id) = self.actions.get(&id.index()) {
277            return Err(ElementCountError::Cycle {
278                kind: ElementNodeKind::Action,
279                id: active_id,
280            });
281        }
282        self.actions.insert(id.index(), node_id);
283        let mut action = self.program.actions.get(id);
284        while let Some(Action::Disabled { action: inner, .. }) = action {
285            action = self.program.actions.get(*inner);
286        }
287        let Some(action) = action else {
288            return Err(ElementCountError::InvalidProgram {
289                message: format!("dangling action {}", id.index()),
290            });
291        };
292        let result = self.action_inner(action, node_id, rule_final);
293        self.actions.remove(&id.index());
294        result
295    }
296
297    fn action_inner(
298        &mut self,
299        action: &Action,
300        node_id: usize,
301        rule_final: bool,
302    ) -> Result<Counted, ElementCountError> {
303        let span = action.span();
304        let mut children = Vec::new();
305        let mut heroes = 0;
306        let mut base = 1;
307        let name;
308        match action {
309            Action::SetGlobalVariable { value, .. }
310            | Action::ModifyGlobalVariable { value, .. } => {
311                name = "variable action";
312                self.push_action_value(&mut children, &mut heroes, *value)?;
313            }
314            Action::SetPlayerVariable { player, value, .. }
315            | Action::ModifyPlayerVariable { player, value, .. } => {
316                name = "player variable action";
317                self.push_action_value(&mut children, &mut heroes, *player)?;
318                self.push_action_value(&mut children, &mut heroes, *value)?;
319            }
320            Action::AssignMember { target, value, .. } => {
321                name = "member assignment";
322                self.push_action_value(&mut children, &mut heroes, *target)?;
323                self.push_action_value(&mut children, &mut heroes, *value)?;
324            }
325            Action::CallSubroutine { .. } => {
326                name = "call subroutine";
327            }
328            Action::If {
329                branches,
330                else_body,
331                ..
332            } => {
333                name = "if";
334                // `Else If` and `Else` are actions of their own; so is `End`. The
335                // canonical emitter closes the last action of a rule without its
336                // `End`, and only that one: nested and loop blocks keep theirs.
337                base += branches.len().saturating_sub(1)
338                    + usize::from(else_body.is_some())
339                    + usize::from(!rule_final);
340                for branch in branches {
341                    self.push_action_value(&mut children, &mut heroes, branch.condition)?;
342                    for nested in &branch.body {
343                        children.push(self.action(*nested, false)?.node);
344                    }
345                }
346                if let Some(body) = else_body {
347                    for nested in body {
348                        children.push(self.action(*nested, false)?.node);
349                    }
350                }
351            }
352            Action::While {
353                condition, body, ..
354            } => {
355                name = "while";
356                base += 1;
357                self.push_action_value(&mut children, &mut heroes, *condition)?;
358                for nested in body {
359                    children.push(self.action(*nested, false)?.node);
360                }
361            }
362            Action::ForGlobalVariable {
363                start,
364                stop,
365                step,
366                body,
367                ..
368            } => {
369                name = "for global variable";
370                base += 1;
371                for value in [start, stop, step] {
372                    self.push_action_value(&mut children, &mut heroes, *value)?;
373                }
374                for nested in body {
375                    children.push(self.action(*nested, false)?.node);
376                }
377            }
378            Action::ForPlayerVariable {
379                player,
380                start,
381                stop,
382                step,
383                body,
384                ..
385            } => {
386                name = "for player variable";
387                base += 1;
388                for value in [player, start, stop, step] {
389                    self.push_action_value(&mut children, &mut heroes, *value)?;
390                }
391                for nested in body {
392                    children.push(self.action(*nested, false)?.node);
393                }
394            }
395            Action::Disabled { .. } => {
396                return Err(ElementCountError::InvalidProgram {
397                    message: "a disabled action wraps no enabled action".to_string(),
398                });
399            }
400            Action::Call {
401                name: action_name,
402                args,
403                ..
404            } => {
405                if self.catalog.entry(Kind::Action, action_name).is_none() {
406                    return Err(ElementCountError::Unsupported {
407                        kind: ElementNodeKind::Action,
408                        name: action_name.clone(),
409                        span,
410                        reason: "the action is not a catalog identity".to_string(),
411                    });
412                }
413                name = action_name.as_str();
414                for (index, argument) in args.iter().enumerate() {
415                    self.name_slot = variable_slot(self.catalog, Kind::Action, action_name, index)
416                        || (index == 0 && takes_variable(self.catalog, Kind::Action, action_name));
417                    self.push_action_value(&mut children, &mut heroes, *argument)?;
418                }
419                // An omitted argument is still a filled slot; its default is a
420                // direct argument, so it costs its own value minus one.
421                base = (base as isize
422                    + omitted_default_cost(
423                        self.catalog,
424                        Kind::Action,
425                        action_name,
426                        args.len(),
427                        true,
428                    ))
429                .max(0) as usize;
430            }
431        }
432        Ok(Counted::finish(
433            ElementNodeKind::Action,
434            node_id,
435            name,
436            span,
437            base,
438            pair_surcharge(heroes),
439            children,
440            heroes,
441        ))
442    }
443
444    fn push_action_value(
445        &mut self,
446        children: &mut Vec<ElementCountNode>,
447        heroes: &mut usize,
448        id: ValueId,
449    ) -> Result<(), ElementCountError> {
450        let counted = self.value(id, true)?;
451        // Pairs are counted within each direct argument, not across them.
452        *heroes += counted.heroes / 2 * 2;
453        children.push(counted.node);
454        Ok(())
455    }
456
457    fn value(&mut self, id: ValueId, top_level: bool) -> Result<Counted, ElementCountError> {
458        let name_slot = std::mem::take(&mut self.name_slot);
459        let node_id = self.next_node_id();
460        if let Some(&active_id) = self.values.get(&id.index()) {
461            return Err(ElementCountError::Cycle {
462                kind: ElementNodeKind::Value,
463                id: active_id,
464            });
465        }
466        self.values.insert(id.index(), node_id);
467        let Some(value) = self.program.values.get(id) else {
468            return Err(ElementCountError::InvalidProgram {
469                message: format!("dangling value {}", id.index()),
470            });
471        };
472        let span = value.span;
473        let result = match &value.value {
474            Value::Number { .. } => self.value_node(node_id, "number", span, 2, vec![], 0),
475            Value::String(_) => self.value_node(node_id, "string", span, 1, vec![], 0),
476            Value::LocalizedString(_) => {
477                self.value_node(node_id, "localized string", span, 2, vec![], 0)
478            }
479            Value::Bool(_) => self.value_node(node_id, "boolean", span, 1, vec![], 0),
480            Value::Null => self.value_node(node_id, "null", span, 1, vec![], 0),
481            Value::Array(elements) => {
482                self.value_children(node_id, "array", span, 2, elements, None)
483            }
484            Value::Vector { x, y, z } => {
485                self.value_children(node_id, "vector", span, 1, &[*x, *y, *z], None)
486            }
487            Value::Enum { value_type, .. } => {
488                let heroes = usize::from(value_type == "Hero");
489                let base = literal_enum_cost(value_type);
490                self.value_node(node_id, value_type, span, base, vec![], heroes)
491            }
492            Value::GlobalVariable(_) => {
493                let base = if name_slot { 1 } else { 2 };
494                self.value_node(node_id, "global variable", span, base, vec![], 0)
495            }
496            Value::PlayerVariable { player, .. } => {
497                // A named player variable is two direct arguments (player, name).
498                let base = if name_slot { 0 } else { 2 };
499                self.value_children(node_id, "player variable", span, base, &[*player], None)
500            }
501            Value::Subroutine(_) => self.value_node(node_id, "subroutine", span, 1, vec![], 0),
502            Value::EventPlayer => self.value_node(node_id, "event player", span, 1, vec![], 0),
503            Value::Call { name, args } => {
504                if name == crate::wir::AMBIGUOUS_ENUM_CALL
505                    && crate::wir::ambiguous_enum_parts(self.program, id).is_some()
506                {
507                    self.value_node(node_id, "ambiguous enum", span, 1, vec![], 0)
508                } else {
509                    if name != "memberAccess"
510                        && self.catalog.entry(Kind::Value, name).is_none()
511                        && self.catalog.entry(Kind::Operator, name).is_none()
512                        && !is_canonical_helper(name)
513                    {
514                        return Err(ElementCountError::Unsupported {
515                            kind: ElementNodeKind::Value,
516                            name: name.clone(),
517                            span,
518                            reason: "the value is not a catalog identity".to_string(),
519                        });
520                    }
521                    let child_ids: Vec<ValueId> = if name == "memberAccess" {
522                        args.first()
523                            .copied()
524                            .into_iter()
525                            .chain(args.iter().copied().skip(2))
526                            .collect()
527                    } else {
528                        args.clone()
529                    };
530                    let base = if is_comparison(name) {
531                        // The operator is a literal of its own.
532                        2
533                    } else if name == "array" || name == "evaluateOnce" {
534                        2
535                    } else {
536                        1
537                    };
538                    let omitted =
539                        omitted_default_cost(self.catalog, Kind::Value, name, args.len(), false);
540                    let mut counted = self.value_children(
541                        node_id,
542                        name,
543                        span,
544                        (base as isize + omitted) as usize,
545                        &child_ids,
546                        Some((Kind::Value, name)),
547                    )?;
548                    let setting = workshop_setting_adjustment(name);
549                    counted.node.adjustment += setting;
550                    counted.node.count = (counted.node.count as isize + setting).max(0) as usize;
551                    Ok(counted)
552                }
553            }
554        }?;
555        self.values.remove(&id.index());
556        let mut result = result;
557        if top_level {
558            result.node.adjustment -= 1;
559            result.node.count = (result.node.count as isize - 1).max(0) as usize;
560        }
561        Ok(result)
562    }
563
564    fn value_node(
565        &self,
566        id: usize,
567        name: impl Into<String>,
568        span: Option<Span>,
569        base: usize,
570        children: Vec<ElementCountNode>,
571        heroes: usize,
572    ) -> Result<Counted, ElementCountError> {
573        Ok(Counted::finish(
574            ElementNodeKind::Value,
575            id,
576            name,
577            span,
578            base,
579            0,
580            children,
581            heroes,
582        ))
583    }
584
585    fn value_children(
586        &mut self,
587        id: usize,
588        name: impl Into<String>,
589        span: Option<Span>,
590        base: usize,
591        ids: &[ValueId],
592        owner: Option<(Kind, &str)>,
593    ) -> Result<Counted, ElementCountError> {
594        let mut children = Vec::with_capacity(ids.len());
595        let mut heroes = 0;
596        for (index, child) in ids.iter().enumerate() {
597            self.name_slot =
598                owner.is_some_and(|(kind, name)| variable_slot(self.catalog, kind, name, index));
599            let counted = self.value(*child, false)?;
600            heroes += counted.heroes;
601            children.push(counted.node);
602        }
603        self.value_node(id, name, span, base, children, heroes)
604    }
605}
606
607fn pair_surcharge(heroes: usize) -> isize {
608    (heroes / 2) as isize
609}
610
611fn is_comparison(name: &str) -> bool {
612    matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=")
613}
614
615fn is_canonical_helper(name: &str) -> bool {
616    matches!(
617        name,
618        "memberAccess"
619            | "+"
620            | "-"
621            | "*"
622            | "/"
623            | "%"
624            | "add"
625            | "subtract"
626            | "multiply"
627            | "divide"
628            | "modulo"
629            | "min"
630            | "max"
631            | "raiseToPower"
632            | "appendToArray"
633            | "removeFromArray"
634            | "removeFromArrayByValue"
635            | "removeFromArrayByIndex"
636    )
637}
638
639/// The cost of the defaults filled in for arguments a call leaves out.
640fn omitted_default_cost(
641    catalog: &Catalog,
642    kind: Kind,
643    name: &str,
644    given: usize,
645    direct: bool,
646) -> isize {
647    let Some(entry) = catalog.entry(kind, name) else {
648        return 0;
649    };
650    (given..entry.param_count())
651        .filter_map(|index| entry.param_default(index))
652        .map(|default| {
653            let cost = if default.parse::<f64>().is_ok() {
654                2
655            } else {
656                default.split('.').next().map_or(1, literal_enum_cost) as isize
657            };
658            cost - isize::from(direct)
659        })
660        .sum()
661}
662
663/// Enum constants the client spells as a value wrapping a literal (`Team(Team 1)`,
664/// `Hero(Ana)`, `Color(White)`, `Button(Reload)`, `Map(...)`) cost both nodes.
665fn literal_enum_cost(value_type: &str) -> usize {
666    if matches!(value_type, "Team" | "Hero" | "Color" | "Button" | "Map") {
667        2
668    } else {
669        1
670    }
671}
672
673/// Whether argument `index` of a call names a variable instead of reading one.
674fn variable_slot(catalog: &Catalog, kind: Kind, name: &str, index: usize) -> bool {
675    catalog.entry(kind, name).is_some_and(|entry| {
676        entry.param_type(index) == Some("Variable")
677            || entry
678                .params()
679                .get(index)
680                .is_some_and(|param| param == "Variable")
681    })
682}
683
684/// Whether a call has a parameter that names a variable; the player and the
685/// variable of a player variable are then one argument here.
686fn takes_variable(catalog: &Catalog, kind: Kind, name: &str) -> bool {
687    catalog.entry(kind, name).is_some_and(|entry| {
688        (0..entry.param_count()).any(|index| variable_slot(catalog, kind, name, index))
689    })
690}
691
692/// The fixed adjustment the client applies to a Workshop setting value, whose
693/// category, name and bounds are literals that are not separate elements.
694fn workshop_setting_adjustment(name: &str) -> isize {
695    match name {
696        "workshopSettingInteger"
697        | "workshopSettingFloat"
698        | "createWorkshopSettingInt"
699        | "createWorkshopSettingFloat" => -3,
700        "workshopSettingCombo" | "createWorkshopSettingEnum" => -2,
701        _ => 0,
702    }
703}