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