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