Repository: Automattic/harper (Rust)

Issue:
Internal word metadata only has a single dialect field but `dictionary.dict` correctly supports multiple dialects per entry
**Describe the bug**

If one word/variant/spelling is used on multiple dialects, it will be marked as an error in all but the first.

For example, "aluminium" and "aluminum" are currently both errors in American English. The same happens for some of the other English dialects.

This is the only word you'll see it with the dialect set to American as it's the only one that has the Canadian `@` before the American `<` dialect attribute flag. But with dialect set to Canadian or Australian it's extremely common and with it set to British it's somewhere in-between.

**To Reproduce**
Steps to reproduce the behavior:
1. If using the American dialect, just type "aluminum".
2. If using Canadian dialect, type any word that shares the American spelling and differs from the British spelling.
3. If using Australian dialect, type any word that shares the British spelling and differs from the American spelling.
4. See error

**Expected behavior**
All correct spellings for the chosen dialect should be allowed without flagging as errors.

**Screenshots**
<img width="877" alt="Image" src="https://github.com/user-attachments/assets/2613e1a7-0bcc-4185-a518-94dba4a1dde6" />


**Platform**
All, including the Chrome extension as shown in the screenshot.




Fix (patch):
diff --git a/Cargo.lock b/Cargo.lock
index b2cce6f1e..b68c0f68b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -179,9 +179,12 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
 [[package]]
 name = "bitflags"
-version = "2.8.0"
+version = "2.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
+checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "blanket"
@@ -845,6 +848,7 @@ name = "harper-core"
 version = "0.40.0"
 dependencies = [
  "ammonia",
+ "bitflags 2.9.1",
  "blanket",
  "cached",
  "criterion",
@@ -1415,7 +1419,7 @@ version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
 dependencies = [
- "bitflags 2.8.0",
+ "bitflags 2.9.1",
  "libc",
 ]
 
@@ -1750,7 +1754,7 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
 dependencies = [
- "bitflags 2.8.0",
+ "bitflags 2.9.1",
  "getopts",
  "memchr",
  "pulldown-cmark-escape",
@@ -1910,7 +1914,7 @@ version = "0.5.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
 dependencies = [
- "bitflags 2.8.0",
+ "bitflags 2.9.1",
 ]
 
 [[package]]
@@ -2519,7 +2523,7 @@ version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
 dependencies = [
- "bitflags 2.8.0",
+ "bitflags 2.9.1",
  "bytes",
  "futures-util",
  "http",
@@ -3327,7 +3331,7 @@ version = "0.39.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
 dependencies = [
- "bitflags 2.8.0",
+ "bitflags 2.9.1",
 ]
 
 [[package]]
diff --git a/harper-core/Cargo.toml b/harper-core/Cargo.toml
index d39f1d0da..b81c54fe4 100644
--- a/harper-core/Cargo.toml
+++ b/harper-core/Cargo.toml
@@ -31,6 +31,7 @@ foldhash = "0.1.5"
 strum_macros = "0.27.1"
 strum = "0.27.1"
 ammonia = "4.1.0"
+bitflags = { version = "2.9.1", features = ["serde"] }
 
 [dev-dependencies]
 criterion = { version = "0.6.0", default-features = false }
diff --git a/harper-core/affixes.json b/harper-core/affixes.json
index 534c9f371..b8482d5ac 100644
--- a/harper-core/affixes.json
+++ b/harper-core/affixes.json
@@ -833,7 +833,7 @@
 			"replacements": [],
 			"target": [],
 			"base_metadata": {
-				"dialect": "American"
+				"dialects": "AMERICAN"
 			}
 		},
 		"!": {
@@ -843,7 +843,7 @@
 			"replacements": [],
 			"target": [],
 			"base_metadata": {
-				"dialect": "British"
+				"dialects": "BRITISH"
 			}
 		},
 		"@": {
@@ -853,7 +853,7 @@
 			"replacements": [],
 			"target": [],
 			"base_metadata": {
-				"dialect": "Canadian"
+				"dialects": "CANADIAN"
 			}
 		},
 		"_": {
@@ -863,7 +863,7 @@
 			"replacements": [],
 			"target": [],
 			"base_metadata": {
-				"dialect": "Australian"
+				"dialects": "AUSTRALIAN"
 			}
 		}
 	}
diff --git a/harper-core/src/linting/spell_check.rs b/harper-core/src/linting/spell_check.rs
index 9e5d0f242..dfaa63478 100644
--- a/harper-core/src/linting/spell_check.rs
+++ b/harper-core/src/linting/spell_check.rs
@@ -52,8 +52,8 @@ impl<T: Dictionary> SpellCheck<T> {
                         self.dictionary
                             .get_word_metadata(v)
                             .unwrap()
-                            .dialect
-                            .is_none_or(|d| d == self.dialect)
+                            .dialects
+                            .is_dialect_enabled(self.dialect)
                     })
                     .map(|v| v.to_smallvec())
                     .take(Self::MAX_SUGGESTIONS)
@@ -77,7 +77,7 @@ impl<T: Dictionary> Linter for SpellCheck<T> {
             let word_chars = document.get_span_content(&word.span);
 
             if let Some(metadata) = word.kind.as_word().unwrap() {
-                if metadata.dialect.is_none_or(|d| d == self.dialect)
+                if metadata.dialects.is_dialect_enabled(self.dialect)
                     && (self.dictionary.contains_exact_word(word_chars)
                         || self.dictionary.contains_exact_word(&word_chars.to_lower()))
                 {
@@ -282,7 +282,6 @@ mod tests {
     }
 
     #[test]
-    #[ignore = "This test is broken due to the metadata dialect field not being a collection"]
     fn australian_labour_vs_labor() {
         assert_lint_count(
             "In Australia we write 'labour' but the political party is the 'Labor Party'.",
@@ -357,7 +356,6 @@ mod tests {
         );
     }
 
-    #[ignore = "Dialect Metadata field currently only allows a single dialect"]
     #[test]
     fn afterwards_is_au() {
         assert_lint_count(
@@ -376,7 +374,6 @@ mod tests {
         );
     }
 
-    #[ignore = "Dialect Metadata field currently only allows a single dialect"]
     #[test]
     fn afterwards_is_ca() {
         assert_lint_count(
diff --git a/harper-core/src/word_metadata.rs b/harper-core/src/word_metadata.rs
index 6796734b2..19ce5bbaa 100644
--- a/harper-core/src/word_metadata.rs
+++ b/harper-core/src/word_metadata.rs
@@ -3,6 +3,8 @@ use paste::paste;
 use serde::{Deserialize, Serialize};
 use strum_macros::{Display, EnumString};
 
+use std::convert::TryFrom;
+
 use crate::WordId;
 
 #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Hash)]
@@ -14,10 +16,11 @@ pub struct WordMetadata {
     pub adverb: Option<AdverbData>,
     pub conjunction: Option<ConjunctionData>,
     pub swear: Option<bool>,
-    /// The dialect this word belongs to.
-    /// If no dialect is defined, it can be assumed that the word is
+    /// The dialects this word belongs to.
+    /// If no dialects are defined, it can be assumed that the word is
     /// valid in all dialects of English.
-    pub dialect: Option<Dialect>,
+    #[serde(default = "default_default")]
+    pub dialects: DialectFlags,
     /// Whether the word is a [determiner](https://en.wikipedia.org/wiki/English_determiners).
     #[serde(default = "default_false")]
     pub determiner: bool,
@@ -41,6 +44,11 @@ fn default_none<T>() -> Option<T> {
     None
 }
 
+/// Needed for `serde`
+fn default_default<T: Default>() -> T {
+    T::default()
+}
+
 macro_rules! generate_metadata_queries {
     ($($category:ident has $($sub:ident),*).*) => {
         paste! {
@@ -106,7 +114,7 @@ impl WordMetadata {
             adjective: merge!(self.adjective, other.adjective),
             adverb: merge!(self.adverb, other.adverb),
             conjunction: merge!(self.conjunction, other.conjunction),
-            dialect: self.dialect.or(other.dialect),
+            dialects: self.dialects | other.dialects,
             swear: self.swear.or(other.swear),
             determiner: self.determiner || other.determiner,
             preposition: self.preposition || other.preposition,
@@ -401,8 +409,94 @@ impl ConjunctionData {
     Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Hash, EnumString, Display,
 )]
 pub enum Dialect {
-    American,
-    Canadian,
-    Australian,
-    British,
+    // Note: these have bit-shifted values so that they can ergonomically integrate with
+    // `DialectFlags`. Each value here must have a unique bit index inside
+    // `DialectsUnderlyingType`.
+    American = 1 << 0,
+    Canadian = 1 << 1,
+    Australian = 1 << 2,
+    British = 1 << 3,
+}
+impl TryFrom<DialectFlags> for Dialect {
+    type Error = ();
+
+    /// Attempts to convert `DialectFlags` to a single `Dialect`.
+    ///
+    /// # Errors
+    ///
+    /// Will return `Err` if more than one dialect is enabled or if an undefined dialect is
+    /// enabled.
+    fn try_from(dialect_flags: DialectFlags) -> Result<Self, Self::Error> {
+        // Ensure only one dialect is enabled before converting.
+        if dialect_flags.bits().count_ones() == 1 {
+            match dialect_flags {
+                df if df.is_dialect_enabled_strict(Dialect::American) => Ok(Dialect::American),
+                df if df.is_dialect_enabled_strict(Dialect::Canadian) => Ok(Dialect::Canadian),
+                df if df.is_dialect_enabled_strict(Dialect::Australian) => Ok(Dialect::Australian),
+                df if df.is_dialect_enabled_strict(Dialect::British) => Ok(Dialect::British),
+                _ => Err(()),
+            }
+        } else {
+            // More than one dialect enabled; can't soundly convert.
+            Err(())
+        }
+    }
+}
+
+// The underlying type used for DialectFlags.
+// At the time of writing, this is currently a `u8`. If we want to define more than 8 dialects in
+// the future, we will need to switch this to a larger type.
+type DialectFlagsUnderlyingType = u8;
+
+bitflags::bitflags! {
+    /// A collection of bit flags used to represent enabled dialects.
+    ///
+    /// This is generally used to allow a word (or similar) to be tagged with multiple dialects.
+    #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Hash)]
+    #[serde(transparent)]
+    pub struct DialectFlags: DialectFlagsUnderlyingType {
+        const AMERICAN = Dialect::American as DialectFlagsUnderlyingType;
+        const CANADIAN = Dialect::Canadian as DialectFlagsUnderlyingType;
+        const AUSTRALIAN = Dialect::Australian as DialectFlagsUnderlyingType;
+        const BRITISH = Dialect::British as DialectFlagsUnderlyingType;
+    }
+}
+impl DialectFlags {
+    /// Checks if the provided dialect is enabled.
+    /// If no dialect is explicitly enabled, it is assumed that all dialects are enabled.
+    #[must_use]
+    pub fn is_dialect_enabled(self, dialect: Dialect) -> bool {
+        self.is_empty() || self.intersects(Self::from_dialect(dialect))
+    }
+
+    /// Checks if the provided dialect is ***explicitly*** enabled.
+    ///
+    /// Unlike `is_dialect_enabled`, this will return false when no dialects are explicitly
+    /// enabled.
+    #[must_use]
+    pub fn is_dialect_enabled_strict(self, dialect: Dialect) -> bool {
+        self.intersects(Self::from_dialect(dialect))
+    }
+
+    /// Constructs a `DialectFlags` from the provided `Dialect`, with only that dialect being
+    /// enabled.
+    ///
+    /// # Panics
+    ///
+    /// This will panic if `dialect` represents a dialect that is not defined in
+    /// `DialectFlags`.
+    #[must_use]
+    pub fn from_dialect(dialect: Dialect) -> Self {
+        let Some(out) = Self::from_bits(dialect as DialectFlagsUnderlyingType) else {
+            panic!("The '{dialect}' dialect isn't defined in DialectFlags!");
+        };
+        out
+    }
+}
+impl Default for DialectFlags {
+    /// A default value with no dialects explicitly enabled.
+    /// Implicitly, this state corresponds to all dialects being enabled.
+    fn default() -> Self {
+        Self::empty()
+    }
 }