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    pub(crate) fn element_count(
168        &self,
169        catalog: &Catalog,
170    ) -> Result<ElementCountReport, ElementCountError> {
171        self.validate()
172            .map_err(|error| ElementCountError::InvalidProgram {
173                message: error.to_string(),
174            })?;
175        crate::rules::validate::validate_wir(self, catalog, false).map_err(|error| {
176            ElementCountError::InvalidProgram {
177                message: error.to_string(),
178            }
179        })?;
180
181        let mut counter = Counter {
182            program: self,
183            catalog,
184            values: HashMap::new(),
185            actions: HashMap::new(),
186            next_node_id: 0,
187            variable_name_arg: false,
188        };
189        let mut rules = Vec::with_capacity(self.rules.len());
190        for rule in self.rules.iter() {
191            rules.push(counter.rule(rule)?);
192        }
193        let total = rules.iter().map(|rule| rule.count).sum();
194        Ok(ElementCountReport { total, rules })
195    }
196}
197
198impl crate::program::Program {
199    /// Count the canonical Workshop target represented by this program.
200    pub fn element_count(
201        &self,
202        catalog: &Catalog,
203    ) -> Result<ElementCountReport, ElementCountError> {
204        let storage = self
205            .to_wir()
206            .map_err(|error| ElementCountError::InvalidProgram {
207                message: error.to_string(),
208            })?;
209        storage.element_count(catalog)
210    }
211}
212
213struct Counted {
214    node: ElementCountNode,
215    heroes: usize,
216}
217
218impl Counted {
219    #[allow(clippy::too_many_arguments)]
220    fn finish(
221        kind: ElementNodeKind,
222        id: usize,
223        name: impl Into<String>,
224        span: Option<Span>,
225        base_count: usize,
226        adjustment: isize,
227        children: Vec<ElementCountNode>,
228        heroes: usize,
229    ) -> Self {
230        let children_count: usize = children.iter().map(|child| child.count).sum();
231        let count = (base_count as isize + children_count as isize + adjustment).max(0) as usize;
232        Self {
233            node: ElementCountNode {
234                kind,
235                id,
236                name: name.into(),
237                span,
238                base_count,
239                adjustment,
240                count,
241                children,
242            },
243            heroes,
244        }
245    }
246}
247
248struct Counter<'a> {
249    program: &'a Program,
250    catalog: &'a Catalog,
251    values: HashMap<usize, usize>,
252    actions: HashMap<usize, usize>,
253    next_node_id: usize,
254    variable_name_arg: bool,
255}
256
257impl Counter<'_> {
258    fn next_node_id(&mut self) -> usize {
259        let id = self.next_node_id;
260        self.next_node_id += 1;
261        id
262    }
263
264    fn rule(&mut self, rule: &wir::Rule) -> Result<ElementCountNode, ElementCountError> {
265        let node_id = self.next_node_id();
266        let mut children = Vec::with_capacity(rule.conditions.len() + rule.actions.len());
267        for condition in &rule.conditions {
268            children.push(self.condition(condition.value)?.node);
269        }
270        for (index, action) in rule.actions.iter().enumerate() {
271            let rule_final = index + 1 == rule.actions.len();
272            children.push(self.action(*action, rule_final)?.node);
273        }
274        Ok(Counted::finish(
275            ElementNodeKind::Rule,
276            node_id,
277            &rule.name,
278            rule.span,
279            1,
280            0,
281            children,
282            0,
283        )
284        .node)
285    }
286
287    fn condition(&mut self, id: ValueId) -> Result<Counted, ElementCountError> {
288        let node_id = self.next_node_id();
289        let Some(value) = self.program.values.get(id) else {
290            return Err(ElementCountError::InvalidProgram {
291                message: format!("dangling condition value {}", id.index()),
292            });
293        };
294        let (children, heroes) = match &value.value {
295            Value::Call { name, args } if wir::is_comparison_operator(name) => {
296                let mut children = Vec::with_capacity(args.len());
297                let mut heroes = 0;
298                for argument in args {
299                    let counted = self.value(*argument, true)?;
300                    heroes += counted.heroes / 2 * 2;
301                    children.push(counted.node);
302                }
303                (children, heroes)
304            }
305            _ => {
306                let counted = self.value(id, true)?;
307                (vec![counted.node], counted.heroes)
308            }
309        };
310        Ok(Counted::finish(
311            ElementNodeKind::Condition,
312            node_id,
313            "condition",
314            value.span,
315            1,
316            pair_surcharge(heroes),
317            children,
318            heroes,
319        ))
320    }
321
322    fn action(&mut self, id: ActionId, rule_final: bool) -> Result<Counted, ElementCountError> {
323        let node_id = self.next_node_id();
324        if let Some(&active_id) = self.actions.get(&id.index()) {
325            return Err(ElementCountError::Cycle {
326                kind: ElementNodeKind::Action,
327                id: active_id,
328            });
329        }
330        self.actions.insert(id.index(), node_id);
331        let mut action = self.program.actions.get(id);
332        while let Some(Action::Disabled { action: inner, .. }) = action {
333            action = self.program.actions.get(*inner);
334        }
335        let Some(action) = action else {
336            return Err(ElementCountError::InvalidProgram {
337                message: format!("dangling action {}", id.index()),
338            });
339        };
340        let result = self.action_inner(action, node_id, rule_final);
341        self.actions.remove(&id.index());
342        result
343    }
344
345    fn action_inner(
346        &mut self,
347        action: &Action,
348        node_id: usize,
349        rule_final: bool,
350    ) -> Result<Counted, ElementCountError> {
351        let span = action.span();
352        let mut children = Vec::new();
353        let mut heroes = 0;
354        let mut base = 1;
355        let name;
356        match action {
357            Action::SetGlobalVariable { value, .. }
358            | Action::ModifyGlobalVariable { value, .. } => {
359                name = "variable action";
360                self.push_action_value(&mut children, &mut heroes, *value)?;
361            }
362            Action::SetPlayerVariable { player, value, .. }
363            | Action::ModifyPlayerVariable { player, value, .. } => {
364                name = "player variable action";
365                self.push_action_value(&mut children, &mut heroes, *player)?;
366                self.push_action_value(&mut children, &mut heroes, *value)?;
367            }
368            Action::AssignMember { target, value, .. } => {
369                name = "member assignment";
370                self.push_action_value(&mut children, &mut heroes, *target)?;
371                self.push_action_value(&mut children, &mut heroes, *value)?;
372            }
373            Action::CallSubroutine { .. } => {
374                name = "call subroutine";
375            }
376            Action::If {
377                branches,
378                else_body,
379                ..
380            } => {
381                name = "if";
382                // `Else If` and `Else` are actions of their own; so is `End`. The
383                // canonical emitter closes the last action of a rule without its
384                // `End`, and only that one: nested and loop blocks keep theirs.
385                base += branches.len().saturating_sub(1)
386                    + usize::from(else_body.is_some())
387                    + usize::from(!rule_final);
388                for branch in branches {
389                    self.push_action_value(&mut children, &mut heroes, branch.condition)?;
390                    for nested in &branch.body {
391                        children.push(self.action(*nested, false)?.node);
392                    }
393                }
394                if let Some(body) = else_body {
395                    for nested in body {
396                        children.push(self.action(*nested, false)?.node);
397                    }
398                }
399            }
400            Action::While {
401                condition, body, ..
402            } => {
403                name = "while";
404                base += 1;
405                self.push_action_value(&mut children, &mut heroes, *condition)?;
406                for nested in body {
407                    children.push(self.action(*nested, false)?.node);
408                }
409            }
410            Action::ForGlobalVariable {
411                start,
412                stop,
413                step,
414                body,
415                ..
416            } => {
417                name = "for global variable";
418                base += 1;
419                for value in [start, stop, step] {
420                    self.push_action_value(&mut children, &mut heroes, *value)?;
421                }
422                for nested in body {
423                    children.push(self.action(*nested, false)?.node);
424                }
425            }
426            Action::ForPlayerVariable {
427                player,
428                start,
429                stop,
430                step,
431                body,
432                ..
433            } => {
434                name = "for player variable";
435                base += 1;
436                for value in [player, start, stop, step] {
437                    self.push_action_value(&mut children, &mut heroes, *value)?;
438                }
439                for nested in body {
440                    children.push(self.action(*nested, false)?.node);
441                }
442            }
443            Action::Disabled { .. } => {
444                return Err(ElementCountError::InvalidProgram {
445                    message: "a disabled action wraps no enabled action".to_string(),
446                });
447            }
448            Action::Call {
449                name: action_name,
450                args,
451                ..
452            } => {
453                if self.catalog.entry(Kind::Action, action_name).is_none() {
454                    return Err(ElementCountError::Unsupported {
455                        kind: ElementNodeKind::Action,
456                        name: action_name.clone(),
457                        span,
458                        reason: "the action is not a catalog identity".to_string(),
459                    });
460                }
461                name = action_name.as_str();
462                for (index, argument) in args.iter().enumerate() {
463                    self.variable_name_arg = variable_slot(
464                        self.program,
465                        self.catalog,
466                        Kind::Action,
467                        action_name,
468                        args,
469                        index,
470                    ) || (index == 0
471                        && takes_variable(self.catalog, Kind::Action, action_name));
472                    self.push_action_value(&mut children, &mut heroes, *argument)?;
473                }
474                // An omitted argument is still a filled slot; its default is a
475                // direct argument, so it costs its own value minus one.
476                base = (base as isize
477                    + omitted_default_cost(
478                        self.program,
479                        self.catalog,
480                        Kind::Action,
481                        action_name,
482                        args,
483                        true,
484                    ))
485                .max(0) as usize;
486            }
487        }
488        Ok(Counted::finish(
489            ElementNodeKind::Action,
490            node_id,
491            name,
492            span,
493            base,
494            pair_surcharge(heroes),
495            children,
496            heroes,
497        ))
498    }
499
500    fn push_action_value(
501        &mut self,
502        children: &mut Vec<ElementCountNode>,
503        heroes: &mut usize,
504        id: ValueId,
505    ) -> Result<(), ElementCountError> {
506        let counted = self.value(id, true)?;
507        // Pairs are counted within each direct argument, not across them.
508        *heroes += counted.heroes / 2 * 2;
509        children.push(counted.node);
510        Ok(())
511    }
512
513    fn value(&mut self, id: ValueId, top_level: bool) -> Result<Counted, ElementCountError> {
514        let variable_name_arg = std::mem::take(&mut self.variable_name_arg);
515        let node_id = self.next_node_id();
516        if let Some(&active_id) = self.values.get(&id.index()) {
517            return Err(ElementCountError::Cycle {
518                kind: ElementNodeKind::Value,
519                id: active_id,
520            });
521        }
522        self.values.insert(id.index(), node_id);
523        let Some(value) = self.program.values.get(id) else {
524            return Err(ElementCountError::InvalidProgram {
525                message: format!("dangling value {}", id.index()),
526            });
527        };
528        let span = value.span;
529        let result = match &value.value {
530            Value::Number { .. } => self.value_node(node_id, "number", span, 2, vec![], 0),
531            Value::String(_) => self.value_node(node_id, "string", span, 1, vec![], 0),
532            Value::LocalizedString(_) => {
533                self.value_node(node_id, "localized string", span, 2, vec![], 0)
534            }
535            Value::Bool(_) => self.value_node(node_id, "boolean", span, 1, vec![], 0),
536            Value::Null => self.value_node(node_id, "null", span, 1, vec![], 0),
537            Value::Array(elements) => {
538                self.value_children(node_id, "array", span, 2, elements, None)
539            }
540            Value::Vector { x, y, z } => {
541                self.value_children(node_id, "vector", span, 1, &[*x, *y, *z], None)
542            }
543            Value::Enum { value_type, .. } => {
544                let heroes = usize::from(value_type == "Hero");
545                let base = literal_enum_cost(value_type);
546                self.value_node(node_id, value_type, span, base, vec![], heroes)
547            }
548            Value::GlobalVariable(_) => {
549                let base = if variable_name_arg { 1 } else { 2 };
550                self.value_node(node_id, "global variable", span, base, vec![], 0)
551            }
552            Value::PlayerVariable { player, .. } => {
553                // A named player variable is two direct arguments (player, name).
554                let base = if variable_name_arg { 0 } else { 2 };
555                self.value_children(node_id, "player variable", span, base, &[*player], None)
556            }
557            Value::Subroutine(_) => self.value_node(node_id, "subroutine", span, 1, vec![], 0),
558            Value::EventPlayer => self.value_node(node_id, "event player", span, 1, vec![], 0),
559            Value::Call { name, args } => {
560                if name == crate::wir::AMBIGUOUS_ENUM_CALL
561                    && crate::wir::ambiguous_enum_parts(self.program, id).is_some()
562                {
563                    self.value_node(node_id, "ambiguous enum", span, 1, vec![], 0)
564                } else {
565                    if name != "memberAccess"
566                        && self.catalog.entry(Kind::Value, name).is_none()
567                        && self.catalog.entry(Kind::Operator, name).is_none()
568                        && !crate::wir::is_canonical_helper(name)
569                    {
570                        return Err(ElementCountError::Unsupported {
571                            kind: ElementNodeKind::Value,
572                            name: name.clone(),
573                            span,
574                            reason: "the value is not a catalog identity".to_string(),
575                        });
576                    }
577                    let child_ids: Vec<ValueId> = if name == "memberAccess" {
578                        args.first()
579                            .copied()
580                            .into_iter()
581                            .chain(args.iter().copied().skip(2))
582                            .collect()
583                    } else {
584                        args.clone()
585                    };
586                    let base = if wir::is_comparison_operator(name) {
587                        // The operator is a literal of its own.
588                        2
589                    } else if name == "array" || name == "evaluateOnce" {
590                        2
591                    } else {
592                        1
593                    };
594                    let omitted = omitted_default_cost(
595                        self.program,
596                        self.catalog,
597                        Kind::Value,
598                        name,
599                        args,
600                        false,
601                    );
602                    let mut counted = self.value_children(
603                        node_id,
604                        name,
605                        span,
606                        (base as isize + omitted) as usize,
607                        &child_ids,
608                        Some((Kind::Value, name)),
609                    )?;
610                    let setting = workshop_setting_adjustment(name);
611                    counted.node.adjustment += setting;
612                    counted.node.count = (counted.node.count as isize + setting).max(0) as usize;
613                    Ok(counted)
614                }
615            }
616        }?;
617        self.values.remove(&id.index());
618        let mut result = result;
619        if top_level {
620            result.node.adjustment -= 1;
621            result.node.count = (result.node.count as isize - 1).max(0) as usize;
622        }
623        Ok(result)
624    }
625
626    fn value_node(
627        &self,
628        id: usize,
629        name: impl Into<String>,
630        span: Option<Span>,
631        base: usize,
632        children: Vec<ElementCountNode>,
633        heroes: usize,
634    ) -> Result<Counted, ElementCountError> {
635        Ok(Counted::finish(
636            ElementNodeKind::Value,
637            id,
638            name,
639            span,
640            base,
641            0,
642            children,
643            heroes,
644        ))
645    }
646
647    fn value_children(
648        &mut self,
649        id: usize,
650        name: impl Into<String>,
651        span: Option<Span>,
652        base: usize,
653        ids: &[ValueId],
654        owner: Option<(Kind, &str)>,
655    ) -> Result<Counted, ElementCountError> {
656        let mut children = Vec::with_capacity(ids.len());
657        let mut heroes = 0;
658        for (index, child) in ids.iter().enumerate() {
659            self.variable_name_arg = owner.is_some_and(|(kind, name)| {
660                variable_slot(self.program, self.catalog, kind, name, ids, index)
661            });
662            let counted = self.value(*child, false)?;
663            heroes += counted.heroes;
664            children.push(counted.node);
665        }
666        self.value_node(id, name, span, base, children, heroes)
667    }
668}
669
670fn pair_surcharge(heroes: usize) -> isize {
671    (heroes / 2) as isize
672}
673
674fn omitted_default_cost(
675    program: &wir::Program,
676    catalog: &Catalog,
677    kind: Kind,
678    name: &str,
679    args: &[ValueId],
680    direct: bool,
681) -> isize {
682    let Some(entry) = catalog.entry(kind, name) else {
683        return 0;
684    };
685    let given = args.len() + usize::from(entry.wir_args_folded(program, args));
686    (given..entry.param_count())
687        .filter_map(|index| entry.param_default(index))
688        .map(|default| {
689            let cost = if default.parse::<f64>().is_ok() {
690                2
691            } else {
692                default.split('.').next().map_or(1, literal_enum_cost) as isize
693            };
694            cost - isize::from(direct)
695        })
696        .sum()
697}
698
699/// Enum constants the client spells as a value wrapping a literal (`Team(Team 1)`,
700/// `Hero(Ana)`, `Color(White)`, `Button(Reload)`, `Map(...)`) cost both nodes.
701fn literal_enum_cost(value_type: &str) -> usize {
702    if matches!(value_type, "Team" | "Hero" | "Color" | "Button" | "Map") {
703        2
704    } else {
705        1
706    }
707}
708
709/// Whether WIR argument `index` of `args` names a variable instead of
710/// reading one, mapped through the entry's surface signature.
711fn variable_slot(
712    program: &wir::Program,
713    catalog: &Catalog,
714    kind: Kind,
715    name: &str,
716    args: &[ValueId],
717    index: usize,
718) -> bool {
719    catalog.entry(kind, name).is_some_and(|entry| {
720        let index = entry.wir_arg_param_index(program, args, index);
721        entry.param_type(index) == Some("Variable")
722            || entry
723                .params()
724                .get(index)
725                .is_some_and(|param| param == "Variable")
726    })
727}
728
729/// Whether a call declares a parameter that names a variable; the player
730/// and the variable of a player variable are then one WIR argument.
731fn takes_variable(catalog: &Catalog, kind: Kind, name: &str) -> bool {
732    catalog.entry(kind, name).is_some_and(|entry| {
733        (0..entry.param_count()).any(|index| {
734            entry.param_type(index) == Some("Variable")
735                || entry
736                    .params()
737                    .get(index)
738                    .is_some_and(|param| param == "Variable")
739        })
740    })
741}
742
743/// The fixed adjustment the client applies to a Workshop setting value, whose
744/// category, name and bounds are literals that are not separate elements.
745fn workshop_setting_adjustment(name: &str) -> isize {
746    match name {
747        "workshopSettingInteger"
748        | "workshopSettingFloat"
749        | "createWorkshopSettingInt"
750        | "createWorkshopSettingFloat" => -3,
751        "workshopSettingCombo" | "createWorkshopSettingEnum" => -2,
752        _ => 0,
753    }
754}