plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


f0eddab — Peter John 2026-10-25T16:45:02+05:30
fix(checker): resolve Ok/Err args from the expected type; tags win over same-named variants
README.md CHANGED
@@ -354,6 +354,8 @@ fun parsePositive(s: Str) -> Try[Int, [BadNumStr, NegativeNum]] =
354
354
  return Ok(n)
355
355
  ```
356
356
 
357
+ Where the expected type is a tag union (a typed return, parameter or field), a name it lists is that tag even if a declared enum variant shares the name; elsewhere the variant wins.
358
+
357
359
  A "missing value" is just an error tag: `List.get` fails with `[OutOfBounds]`, `first`/`last`/`pop` with `[ListWasEmpty]`, `Map.get` with `[KeyNotFound]`, `parseInt`/`parseFloat` with `[BadNumStr]`, `Json.fromStr` with `[InvalidJson(Str)]`.
358
360
 
359
361
  `expr?` unwraps an `Ok`, or exits the enclosing function early with the `Err` value — Rust's `?`. Tag unions merge automatically across `?`: the function's declared union just has to cover every union it propagates:
@@ -377,8 +379,8 @@ fun parseOrInvalid(s: Str) -> Try[Int, [InvalidInput]] =
377
379
 
378
380
  ```plum
379
381
  fun sumOrZero(a: Str, b: Str) -> Int =
380
- x := {parsePositive(a) ?? 0}
382
+ x := parsePositive(a) ?? 0
381
- y := {parsePositive(b) ?? 0}
383
+ y := parsePositive(b) ?? 0
382
384
  x + y
383
385
  ```
384
386
 
plum-checker/src/lib.rs CHANGED
@@ -1761,10 +1761,37 @@ pub fn tryOperatorVariants<'a>(
1761
1761
  }
1762
1762
  }
1763
1763
 
1764
+ /// Prefix the monomorphizer puts on a tag literal whose name a declared variant
1765
+ /// also uses, at a site whose expected type is a tag union naming it
1766
+ /// (`Err(Bad)` in `-> Try[Int, [Bad]]` next to `enum Rating = | Bad(...)`).
1767
+ /// Not a legal identifier character, so it can't clash with source names.
1768
+ pub const TAG_MARKER: char = '#';
1769
+
1770
+ /// The tag name a tag-literal expression name stands for (drops `TAG_MARKER`).
1771
+ pub fn tagLiteralName(n: &str) -> &str {
1772
+ n.strip_prefix(TAG_MARKER).unwrap_or(n)
1773
+ }
1774
+
1775
+ /// True if `n` is the base name of a generic enum, or of one of its variants,
1776
+ /// that only survives monomorphization under mangled names (`Ok` ->
1777
+ /// `Ok$Int$[X]`, `Try` -> `Try$Int$[X]`).
1778
+ fn isGenericEnumBaseName(n: &str, ctx: &CheckCtx) -> bool {
1779
+ let base = |k: &str| k.split_once('$').map(|(b, _)| b == n).unwrap_or(false);
1780
+ !ctx.enum_variants.contains_key(n)
1781
+ && ctx
1782
+ .enum_variants
1783
+ .iter()
1784
+ .any(|(k, info)| base(k) || base(&info.enum_name))
1785
+ }
1786
+
1764
1787
  /// True if `n` is an uppercase name that is not a declared type, variant, method
1765
1788
  /// receiver, builtin type or const — i.e. a structural tag literal.
1766
1789
  fn isTagLiteralName(n: &str, ctx: &CheckCtx) -> bool {
1790
+ if n.starts_with(TAG_MARKER) {
1791
+ return true;
1792
+ }
1767
1793
  n.chars().next().is_some_and(|c| c.is_uppercase())
1794
+ && !isGenericEnumBaseName(n, ctx)
1768
1795
  && !matches!(
1769
1796
  n,
1770
1797
  "Int" | "Float" | "Bool" | "Str" | "Byte" | "Unit" | "Self"
@@ -1799,9 +1826,10 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1799
1826
  // keeps that working without patching every such site.
1800
1827
  Some(info) if info.enum_name == "Bool" => Ok(PlumType::TBool),
1801
1828
  Some(info) => Ok(PlumType::TNamed(info.enum_name.clone())),
1802
- None if isTagLiteralName(n, ctx) => {
1829
+ None if isTagLiteralName(n, ctx) => Ok(PlumType::TTags(BTreeMap::from([(
1803
- Ok(PlumType::TTags(BTreeMap::from([(n.clone(), None)])))
1830
+ tagLiteralName(n).to_string(),
1804
- }
1831
+ None,
1832
+ )]))),
1805
1833
  // Unmodeled/builtin type name: allow, codegen will catch.
1806
1834
  None => Ok(PlumType::TNamed(n.to_string())),
1807
1835
  },
@@ -2180,8 +2208,17 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
2180
2208
  }
2181
2209
  None => None,
2182
2210
  };
2183
- Ok(PlumType::TTags(BTreeMap::from([(call.name.clone(), payload)])))
2211
+ Ok(PlumType::TTags(BTreeMap::from([(
2212
+ tagLiteralName(&call.name).to_string(),
2213
+ payload,
2214
+ )])))
2184
2215
  }
2216
+ // `Ok(3)` that nothing pinned to a concrete specialization (no
2217
+ // typed param/field/return to take `E` from) — it is never a tag.
2218
+ Err(_) if isGenericEnumBaseName(&call.name, ctx) => Err(format!(
2219
+ "cannot infer the type parameters of '{}' here: use it where its full type is known (a typed parameter, field or return)",
2220
+ call.name
2221
+ )),
2185
2222
  Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
2186
2223
  }
2187
2224
  }
plum-checker/src/monomorphize.rs CHANGED
@@ -1259,6 +1259,7 @@ impl<'a> Monomorphizer<'a> {
1259
1259
  if let Some(idx) = info.field_names.iter().position(|n| n == &fa.name) {
1260
1260
  let expected = info.field_types[idx].clone();
1261
1261
  self.resolveBareVariantAgainstExpected(&mut fa.value, &expected);
1262
+ self.resolveGenericVariantAgainstExpected(&mut fa.value, &expected);
1262
1263
  self.wrapPrimitiveAgainstExpected(&mut fa.value, &expected, env);
1263
1264
  }
1264
1265
  }
@@ -1683,6 +1684,7 @@ impl<'a> Monomorphizer<'a> {
1683
1684
  /// gets there. No-op if `expr` isn't a bare generic-enum variant, or
1684
1685
  /// `expected` doesn't name a specialization of the SAME enum.
1685
1686
  fn resolveBareVariantAgainstExpected(&mut self, expr: &mut ast::Expr, expected: &PlumType) {
1687
+ self.markTagsAgainstExpected(expr, expected);
1686
1688
  let ast::Expr::TypeName(n) = expr else { return };
1687
1689
  let Some(e) = self.enums_generic_by_variant.get(n.as_str()).copied() else {
1688
1690
  return;
@@ -1700,6 +1702,77 @@ impl<'a> Monomorphizer<'a> {
1700
1702
  }
1701
1703
  }
1702
1704
 
1705
+ /// A tag literal (`Bad` / `Bad(x)`) whose expected type is a tag union
1706
+ /// naming it, but whose name a declared variant also uses (`enum Rating =
1707
+ /// | Bad(...)`): prefixes it with `crate::TAG_MARKER` so the checker and
1708
+ /// codegen treat it as the tag, not the variant. Recurses into an
1709
+ /// already-specialized variant construction's payloads (`Err$Int$[Bad](Bad)`)
1710
+ /// against that variant's own field types.
1711
+ fn markTagsAgainstExpected(&self, expr: &mut ast::Expr, expected: &PlumType) {
1712
+ let collides = |n: &str| {
1713
+ self.enum_variants.contains_key(n) || self.enums_generic_by_variant.contains_key(n)
1714
+ };
1715
+ match expr {
1716
+ ast::Expr::Paren(inner) => self.markTagsAgainstExpected(inner, expected),
1717
+ ast::Expr::TypeName(n) => {
1718
+ if let PlumType::TTags(tags) = expected {
1719
+ if matches!(tags.get(n.as_str()), Some(None)) && collides(n) {
1720
+ *n = format!("{}{}", crate::TAG_MARKER, n);
1721
+ }
1722
+ }
1723
+ }
1724
+ ast::Expr::FnCall(call) => {
1725
+ if let PlumType::TTags(tags) = expected {
1726
+ if call.args.len() == 1
1727
+ && matches!(tags.get(call.name.as_str()), Some(Some(_)))
1728
+ && collides(&call.name)
1729
+ {
1730
+ call.name = format!("{}{}", crate::TAG_MARKER, call.name);
1731
+ }
1732
+ return;
1733
+ }
1734
+ if self.enums_generic_by_variant.contains_key(call.name.as_str()) {
1735
+ return; // not specialized yet: no concrete field types
1736
+ }
1737
+ if let Some(info) = self.enum_variants.get(call.name.as_str()) {
1738
+ for (arg, ft) in call.args.iter_mut().zip(info.field_types.iter()) {
1739
+ let e = match arg {
1740
+ ast::Arg::Positional(e) => e,
1741
+ ast::Arg::Keyword { value, .. } => value,
1742
+ ast::Arg::Pair { value, .. } => value,
1743
+ };
1744
+ self.markTagsAgainstExpected(e, ft);
1745
+ }
1746
+ }
1747
+ }
1748
+ _ => {}
1749
+ }
1750
+ }
1751
+
1752
+ /// `Ok(3)` passed where a concrete `Try$Int$[X]` is expected (a typed
1753
+ /// param or field): a generic variant whose own payload can't pin every
1754
+ /// enum param takes its specialization from that expected type instead —
1755
+ /// the arg/field analogue of `resolveEnumInstantiation`'s return-type
1756
+ /// fallback. Also marks colliding tag payloads (`Err(Bad)`).
1757
+ fn resolveGenericVariantAgainstExpected(&mut self, expr: &mut ast::Expr, expected: &PlumType) {
1758
+ if let ast::Expr::FnCall(call) = expr {
1759
+ if let Some(e) = self.enums_generic_by_variant.get(call.name.as_str()).copied() {
1760
+ if let PlumType::TNamed(mangled) = expected {
1761
+ if mangled.starts_with(&format!("{}$", e.name)) {
1762
+ if let Some(mv) = self
1763
+ .enum_variant_mangling
1764
+ .get(mangled)
1765
+ .and_then(|table| table.get(&call.name))
1766
+ {
1767
+ call.name = mv.clone();
1768
+ }
1769
+ }
1770
+ }
1771
+ }
1772
+ }
1773
+ self.markTagsAgainstExpected(expr, expected);
1774
+ }
1775
+
1703
1776
  /// If `expr`'s own inferred type is a builtin primitive (`Int`/`Float`)
1704
1777
  /// and `expected` names a "union" enum with a bare-primitive-wrap variant
1705
1778
  /// matching that exact primitive (`enum Number = | Int | Float` — see
@@ -2054,6 +2127,32 @@ impl<'a> Monomorphizer<'a> {
2054
2127
  let Some(variant) = e.variants.iter().find(|v| v.name == call.name) else {
2055
2128
  return Ok(());
2056
2129
  };
2130
+ // A payload whose param the enclosing return type pins to a tag union
2131
+ // (`return Err(Bad)` in `-> Try[Int, [Bad]]`) is that tag, even when a
2132
+ // declared variant shares its name — mark it before inferring from it.
2133
+ if let Some(rt) = self.current_return_type.clone() {
2134
+ if rt.name == e.name && rt.generics.len() == params.len() {
2135
+ for (field_ty, arg) in variant.fields.iter().zip(call.args.iter_mut()) {
2136
+ let Some(pi) = params.iter().position(|p| p == &field_ty.name) else {
2137
+ continue;
2138
+ };
2139
+ let gt = &rt.generics[pi];
2140
+ if gt.generics.is_empty() && isGenericParamName(&gt.name) {
2141
+ continue;
2142
+ }
2143
+ let resolved = self.resolveFieldType(gt);
2144
+ let expected = crate::plumTypeFromAst(&resolved);
2145
+ if matches!(expected, PlumType::TTags(_)) {
2146
+ let arg_expr = match arg {
2147
+ ast::Arg::Positional(e) => e,
2148
+ ast::Arg::Keyword { value, .. } => value,
2149
+ ast::Arg::Pair { value, .. } => value,
2150
+ };
2151
+ self.markTagsAgainstExpected(arg_expr, &expected);
2152
+ }
2153
+ }
2154
+ }
2155
+ }
2057
2156
  let mut bindings: BTreeMap<String, PlumType> = BTreeMap::new();
2058
2157
  for (field_ty, arg) in variant.fields.iter().zip(call.args.iter()) {
2059
2158
  if params.contains(&field_ty.name) {
@@ -2476,6 +2575,7 @@ impl<'a> Monomorphizer<'a> {
2476
2575
  });
2477
2576
  if let Some(expected) = expected_ty {
2478
2577
  self.wrapPrimitiveAgainstExpected(&mut fa.value, &expected, env);
2578
+ self.resolveGenericVariantAgainstExpected(&mut fa.value, &expected);
2479
2579
  }
2480
2580
  }
2481
2581
  // Runs next (before the generic per-field rewrite below): a
@@ -2539,6 +2639,7 @@ impl<'a> Monomorphizer<'a> {
2539
2639
  ast::Arg::Pair { value, .. } => value,
2540
2640
  };
2541
2641
  self.wrapPrimitiveAgainstExpected(e, expected, env);
2642
+ self.resolveGenericVariantAgainstExpected(e, expected);
2542
2643
  }
2543
2644
  }
2544
2645
  for arg in &mut call.args {
@@ -2619,6 +2720,7 @@ impl<'a> Monomorphizer<'a> {
2619
2720
  };
2620
2721
  if !matches!(e, ast::Expr::Closure(_)) {
2621
2722
  self.wrapPrimitiveAgainstExpected(e, expected, env);
2723
+ self.resolveGenericVariantAgainstExpected(e, expected);
2622
2724
  }
2623
2725
  }
2624
2726
  }
@@ -2918,6 +3020,32 @@ pub fn monomorphizeSource(source: &ast::Source) -> Result<ast::Source, String> {
2918
3020
  }
2919
3021
  }
2920
3022
 
3023
+ // Same reasoning for a non-generic enum's variant field types: a
3024
+ // construction (`Holder(t: Ok(5))`) in an item EARLIER than the enum's own
3025
+ // declaration must already see `Try$Int$[X]`, not the stale bare `Try`,
3026
+ // to resolve its field values against (the main loop below refreshes
3027
+ // them again, idempotently, when it reaches the enum).
3028
+ for item in &source.items {
3029
+ if let ast::Item::Enum(e) = item {
3030
+ if !enumGenericParams(e).is_empty() {
3031
+ continue;
3032
+ }
3033
+ for v in &e.variants {
3034
+ if v.fields.is_empty() || !m.enum_variants.contains_key(v.name.as_str()) {
3035
+ continue;
3036
+ }
3037
+ let field_types: Vec<PlumType> = v
3038
+ .fields
3039
+ .iter()
3040
+ .map(|f| crate::plumTypeFromAst(&m.resolveFieldType(f)))
3041
+ .collect();
3042
+ if let Some(info) = m.enum_variants.get_mut(v.name.as_str()) {
3043
+ info.field_types = field_types;
3044
+ }
3045
+ }
3046
+ }
3047
+ }
3048
+
2921
3049
  for item in &source.items {
2922
3050
  match item {
2923
3051
  ast::Item::Enum(e) if enumGenericParams(e).is_empty() => {
plum-checker/tests/checker_tests.rs CHANGED
@@ -1674,11 +1674,23 @@ enum Color =
1674
1674
  | Red
1675
1675
  | Blue
1676
1676
 
1677
- fun f() -> Try[Int, [Red]] =
1677
+ fun f() -> Try[Int, [Green]] =
1678
1678
  Err(Red)
1679
1679
  ",
1680
1680
  );
1681
1681
  assert!(bad.is_err());
1682
+ // Where the expected type is a tag union naming it, `Red` is the tag.
1683
+ let tag = checkTry(
1684
+ "\
1685
+ enum Color =
1686
+ | Red
1687
+ | Blue
1688
+
1689
+ fun f() -> Try[Int, [Red]] =
1690
+ Err(Red)
1691
+ ",
1692
+ );
1693
+ assert!(tag.is_ok(), "got {:?}", tag.err());
1682
1694
  }
1683
1695
 
1684
1696
  #[test]
@@ -1707,3 +1719,72 @@ fun main() =
1707
1719
  let mono = plum_checker::monomorphize::monomorphizeSource(&source);
1708
1720
  assert!(mono.is_ok(), "expected Ok, got {:?}", mono.err());
1709
1721
  }
1722
+
1723
+ #[test]
1724
+ fn okErrPassedDirectlyAsArgOrFieldTakeExpectedSpecialization() {
1725
+ // `Ok(3)` only pins `T`; `E` must come from the param/field type, and the
1726
+ // generic variant's base name `Ok` must never be read as a tag literal.
1727
+ // `Holder` is declared AFTER its use on purpose.
1728
+ let src = "\
1729
+ fun take(t: Try[Int, [Nope]]) -> Int =
1730
+ match t
1731
+ Ok(v) => v
1732
+ Err(_) => 0
1733
+
1734
+ fun mk() -> Holder =
1735
+ Holder(t: Ok(4))
1736
+
1737
+ enum Holder =
1738
+ | Holder(t: Try[Int, [Nope]])
1739
+
1740
+ fun put(self, t: Try[Int, [Nope]]) -> Int =
1741
+ take(t)
1742
+
1743
+ fun main() -> Int =
1744
+ take(Ok(3)) + take(Err(Nope)) + mk().put(Ok(5))
1745
+ ";
1746
+ let r = checkTry(src);
1747
+ assert!(r.is_ok(), "expected Ok, got {}", errText(r));
1748
+ }
1749
+
1750
+ #[test]
1751
+ fn unpinnedOkIsNotATagLiteral() {
1752
+ // (needs some concrete `Try` specialization to exist, as in any real
1753
+ // program importing std/Try; with none, the checker can't tell `Ok` from a tag)
1754
+ let r = checkTry("fun one() -> Try[Int, [Nope]] =\n Ok(1)\n\nfun main() =\n x := Ok(3)\n");
1755
+ let msg = errText(r);
1756
+ assert!(msg.contains("cannot infer the type parameters of 'Ok'"), "{}", msg);
1757
+ assert!(!msg.contains("[Ok("), "{}", msg);
1758
+ }
1759
+
1760
+ #[test]
1761
+ fn tagNamedLikeAVariantIsTheTagWhereAUnionIsExpected() {
1762
+ let src = "\
1763
+ enum Rating =
1764
+ | Good(name: Str)
1765
+ | Bad(name: Str)
1766
+
1767
+ fun f() -> Try[Int, [Bad]] =
1768
+ return Err(Bad)
1769
+
1770
+ fun g() -> [Bad, Worse] =
1771
+ Bad
1772
+
1773
+ fun code(t: [Bad, Worse]) -> Int =
1774
+ match t
1775
+ Bad => 1
1776
+ Worse => 2
1777
+
1778
+ fun errCode(t: Try[Int, [Bad, Worse]]) -> Int =
1779
+ match t
1780
+ Ok(_) => 0
1781
+ Err(Bad) => 1
1782
+ Err(Worse) => 2
1783
+
1784
+ fun main() -> Int =
1785
+ r := Bad(name: \"x\")
1786
+ code(g()) + code(Bad) + errCode(Err(Bad)) + errCode(f())
1787
+ ";
1788
+ let r = checkTry(src);
1789
+ assert!(r.is_ok(), "expected Ok, got {}", errText(r));
1790
+ }
plum-examples/dop_visitor.plum CHANGED
@@ -43,7 +43,7 @@ import std/Str
43
43
 
44
44
  enum Rating =
45
45
  | Good(name: Str)
46
- | Poor(name: Str)
46
+ | Bad(name: Str)
47
47
 
48
48
  enum Book =
49
49
  | FantasyBook(title: Str, pages: Int, hasMythicalCreatures: Bool)
@@ -107,6 +107,6 @@ test "non fiction book with a bad rating reports mixed reviews"
107
107
  title: "Shaky Claims",
108
108
  pages: 80,
109
109
  rating1: Good(name: "Alice"),
110
- rating2: Poor(name: "Carol"),
110
+ rating2: Bad(name: "Carol"),
111
111
  )
112
112
  assert collectInterestingInfo(book) == "Non-fiction book \"Shaky Claims\" has mixed reviews"
plum-examples/match.plum CHANGED
@@ -125,9 +125,6 @@ fun combine(a: Int, b: Int) -> Int =
125
125
  x, 0 => x
126
126
  x, y => x + y
127
127
 
128
- fun okInt(n: Int) -> Try[Int, [Missing]] =
129
- return Ok(n)
130
-
131
128
  fun bothOptions(a: Try[Int, [Missing]], b: Try[Int, [Missing]]) -> Int =
132
129
  match a, b
133
130
  Ok(x), Ok(y) => x + y
@@ -194,7 +191,7 @@ test "non bool bare tag pattern runs correctly"
194
191
  assert colorCode(Green) == 2
195
192
 
196
193
  test "constructor pattern wildcard field runs correctly"
197
- assert isSome(okInt(99)) == 1
194
+ assert isSome(Ok(99)) == 1
198
195
 
199
196
  test "constructor pattern does not misfire on payload free sibling"
200
197
  assert describeOption(Absent) == 0
@@ -212,7 +209,7 @@ test "multi subject match with binding and wildcard runs correctly"
212
209
  assert combine(3, 4) == 7
213
210
 
214
211
  test "multi subject match with generic enum variant runs correctly"
215
- assert bothOptions(okInt(3), okInt(4)) == 7
212
+ assert bothOptions(Ok(3), Ok(4)) == 7
216
213
 
217
214
  test "match destructures a plain class the same way it destructures an enum variant"
218
215
  assert Point(x: 3, y: 4).sum() == 7
plum-examples/oop_visitor.plum CHANGED
@@ -36,7 +36,7 @@ import std/Str
36
36
 
37
37
  enum Rating =
38
38
  | Good(name: Str)
39
- | Poor(name: Str)
39
+ | Bad(name: Str)
40
40
 
41
41
  trait Book =
42
42
  collectInterestingInfo() -> Str
@@ -119,6 +119,6 @@ test "non fiction book with a bad rating reports mixed reviews"
119
119
  title: "Shaky Claims",
120
120
  pages: 80,
121
121
  rating1: Good(name: "Alice"),
122
- rating2: Poor(name: "Carol"),
122
+ rating2: Bad(name: "Carol"),
123
123
  )
124
124
  assert book.collectInterestingInfo() == "Non-fiction book \"Shaky Claims\" has mixed reviews"
plum-examples/types.plum CHANGED
@@ -88,9 +88,6 @@ fun stepToNumber(s: Step) -> Int =
88
88
  ReadMin => 1
89
89
  ReadMax => 2
90
90
 
91
- fun okIntForTypes(n: Int) -> Try[Int, [Missing]] =
92
- return Ok(n)
93
-
94
91
  fun unwrapTryOr(o: Try[Int, [Missing]], default: Int) -> Int =
95
92
  match o
96
93
  Ok(v) =>
@@ -155,7 +152,7 @@ test "enum discriminant value matches by variant name correctly"
155
152
  assert stepToNumber(ReadMin) * 10 + stepToNumber(ReadMax) == 12
156
153
 
157
154
  test "payload variant construction compiles and runs"
158
- assert unwrapTryOr(okIntForTypes(7), 0) == 7
155
+ assert unwrapTryOr(Ok(7), 0) == 7
159
156
 
160
157
  test "multi field variant construction compiles and runs"
161
158
  assert area(Rect(3, 4)) == 12
@@ -197,7 +194,7 @@ test "a bare Int/Float value dispatches a method defined only on Number, via fal
197
194
  assert {2.5}.kind() == "Float"
198
195
 
199
196
  test "enum class field construct and destructure runs correctly"
200
- b := TryBox(value: okIntForTypes(42))
197
+ b := TryBox(value: Ok(42))
201
198
  assert b.unwrap(0) == 42
202
199
 
203
200
  test "class field mutation and spread update run correctly"
plum-wasm-codegen/src/lib.rs CHANGED
@@ -5843,7 +5843,11 @@ fn compileExpr(
5843
5843
  compileVariantConstruction(info, call, expr, body, ctx, state)?;
5844
5844
  } else if !ctx.func_ids.contains_key(&call.name)
5845
5845
  && call.args.len() <= 1
5846
+ && call
5847
+ .name
5848
+ .chars()
5849
+ .next()
5846
- && call.name.chars().next().is_some_and(|c| c.is_uppercase())
5850
+ .is_some_and(|c| c.is_uppercase() || c == plum_checker::TAG_MARKER)
5847
5851
  && matches!(inferLocalType(expr, ctx), PlumType::TTags(_))
5848
5852
  {
5849
5853
  // `Bad(x)` / `Bad()` — an undeclared uppercase name is a tag literal.
@@ -5852,7 +5856,13 @@ fn compileExpr(
5852
5856
  ast::Arg::Keyword { value, .. } => value,
5853
5857
  ast::Arg::Pair { value, .. } => value,
5854
5858
  });
5859
+ compileTagLiteral(
5855
- compileTagLiteral(&call.name, payload, body, ctx, state)?;
5860
+ plum_checker::tagLiteralName(&call.name),
5861
+ payload,
5862
+ body,
5863
+ ctx,
5864
+ state,
5865
+ )?;
5856
5866
  } else {
5857
5867
  fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
5858
5868
  match arg {
@@ -5953,10 +5963,13 @@ fn compileExpr(
5953
5963
  let const_value = CURRENT_CONSTS.with(|c| c.borrow().get(n).cloned());
5954
5964
  match const_value {
5955
5965
  Some(value) => compileExpr(&value, body, ctx, state)?,
5966
+ None if n
5967
+ .chars()
5968
+ .next()
5956
- None if n.chars().next().is_some_and(|c| c.is_uppercase())
5969
+ .is_some_and(|c| c.is_uppercase() || c == plum_checker::TAG_MARKER)
5957
5970
  && matches!(inferLocalType(expr, ctx), PlumType::TTags(_)) =>
5958
5971
  {
5959
- compileTagLiteral(n, None, body, ctx, state)?
5972
+ compileTagLiteral(plum_checker::tagLiteralName(n), None, body, ctx, state)?
5960
5973
  }
5961
5974
  None => {
5962
5975
  return Err(format!(