collective

Browse / github.com/storytold/gridcraft / patch

i18n: Turkish formula entry (function names, separators)

i18n: Turkish formula entry (function names, separators) Adds FormulaLocale::Tr, picked by the Turkish interface as Spanish is: Excel's Turkish names for 439 functions (crates/formula/src/turkish.rs: TOPLA, EĞER, DÜŞEYARA, ETOPLA, İNDİS, KAÇINCI…), DOĞRU/YANLIŞ, `;` arguments, decimal comma, and `;` columns / `\` rows in array constants. - Names follow Turkish case rules (`indis` = İNDİS, `satır` = SATIR) in parsing, autocomplete, argument hints and Ctrl+Shift+A; the editor's word scan accepts non-ASCII letters. - English names stay accepted; functions Turkish Excel leaves in English (LET, LAMBDA, BYROW, MOD…) and those whose Turkish name could not

Status
open
Score
0.00
Adopters
0 other operators
Author
erolsavas19 (imported from pull request #2760, open)
Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc
Size
+716 −54 in 12 files
Review
pending
Shared
2026-10-28
Id
85d38d8f4b0670273d0b80e1

How it went

No other operator has reported using it yet.

Diff

raw · crates/formula/src/input.rs crates/formula/src/lexer.rs crates/formula/src/lib.rs crates/formula/src/parser.rs crates/formula/src/turkish.rs crates/formula/tests/it/main.rs crates/formula/tests/it/turkish_input.rs crates/ui-egui/src/editor.rs crates/ui-egui/src/i18n.rs crates/ui-egui/tests/it/formula_locale.rs docs/formula-input.md docs/localization-parity.md

diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs--- a/crates/formula/src/input.rs+++ b/crates/formula/src/input.rs@@ -9,6 +9,7 @@ pub enum FormulaLocale {     #[default]     En,     Es,+    Tr, }  const SPANISH: &[(&str, &str)] = &[@@ -32,36 +33,60 @@ const SPANISH: &[(&str, &str)] = &[  impl FormulaLocale {     pub fn list_separator(self) -> char {-        if self == Self::Es { ';' } else { ',' }+        if self == Self::En { ',' } else { ';' }     }     pub fn decimal_separator(self) -> char {-        if self == Self::Es { ',' } else { '.' }+        if self == Self::En { '.' } else { ',' }     }     pub fn array_column_separator(self) -> char {-        if self == Self::Es { '\\' } else { ',' }+        match self {+            Self::En => ',',+            Self::Es => '\\',+            Self::Tr => ';',+        }     }-    pub fn function_name(self, canonical: &str) -> &str {-        if self == Self::Es {-            SPANISH.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, es)| *es).unwrap_or(canonical)-        } else {-            canonical+    /// Between the rows of an array constant: `{1;2\3;4}` in Turkish Excel.+    pub fn array_row_separator(self) -> char {+        if self == Self::Tr { '\\' } else { ';' }+    }+    fn table(self) -> &'static [(&'static str, &'static str)] {+        match self {+            Self::En => &[],+            Self::Es => SPANISH,+            Self::Tr => crate::turkish::TURKISH,         }     }-    pub fn canonical_name(self, localized: &str) -> &str {-        if self == Self::Es {-            SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized)+    /// Upper case as the locale's users type it: Turkish `i` is `İ` and `ı` is `I` (`eğer` → `EĞER`).+    pub fn upper(self, text: &str) -> String {+        if self == Self::Tr {+            text.chars()+                .flat_map(|c| match c {+                    'i' => ['İ'].into_iter().flat_map(char::to_uppercase),+                    'ı' => ['I'].into_iter().flat_map(char::to_uppercase),+                    c => [c].into_iter().flat_map(char::to_uppercase),+                })+                .collect()         } else {-            localized+            text.to_uppercase()         }     }+    fn same_alias(self, a: &str, b: &str) -> bool {+        if a.is_ascii() && b.is_ascii() { a.eq_ignore_ascii_case(b) } else { self.upper(a) == self.upper(b) }+    }+    pub fn function_name(self, canonical: &str) -> &str {+        self.table().iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, local)| *local).unwrap_or(canonical)+    }+    pub fn canonical_name(self, localized: &str) -> &str {+        self.table().iter().find(|(_, local)| self.same_alias(local, localized)).map(|(en, _)| *en).unwrap_or(localized)+    } }  /// Parses UI input using the selected function table and separators. Existing names and /// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`]. pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {     let mut expr = parser::parse_syntax(src, locale)?;-    if locale == FormulaLocale::Es {-        normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;+    if locale != FormulaLocale::En {+        normalize(&mut expr, locale, &known_name, &mut Vec::new(), 0, false)?;     }     Ok(expr) }@@ -96,7 +121,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)         return Ok(crate::print(expr));     }     let mut expr = expr.clone();-    normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;+    normalize(&mut expr, locale, &known_name, &mut Vec::new(), 0, true)?;     let canonical = crate::print(&expr);     let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;     let mut out = String::new();@@ -115,36 +140,47 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)             }             Tok::Number(_) => out.push_str(&text.replace('.', &locale.decimal_separator().to_string())),             Tok::Comma => out.push(if array_depth > 0 { locale.array_column_separator() } else { locale.list_separator() }),+            Tok::Semicolon if array_depth > 0 => out.push(locale.array_row_separator()),             _ => out.push_str(text),         }     }     Ok(out) } -pub(crate) fn boolean_alias(name: &str) -> Option<bool> {-    if name.eq_ignore_ascii_case("VERDADERO") {+/// `VERDADERO` / `DOĞRU`… typed as a value: the locale's names for TRUE and FALSE.+pub(crate) fn boolean_alias(locale: FormulaLocale, name: &str) -> Option<bool> {+    if locale == FormulaLocale::En {+        None+    } else if locale.same_alias(name, locale.function_name("TRUE")) {         Some(true)-    } else if name.eq_ignore_ascii_case("FALSO") {+    } else if locale.same_alias(name, locale.function_name("FALSE")) {         Some(false)     } else {         None     } } -fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {+fn normalize(+    expr: &mut Expr,+    locale: FormulaLocale,+    known_name: &impl Fn(&str) -> bool,+    bindings: &mut Vec<String>,+    depth: usize,+    display: bool,+) -> Result<(), ParseError> {     if depth > parser::MAX_DEPTH {         return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });     }     let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);     match expr {         Expr::Bool(value) if display => {-            let alias = if *value { "VERDADERO" } else { "FALSO" };+            let alias = locale.function_name(if *value { "TRUE" } else { "FALSE" });             if !bound(alias, bindings) {                 *expr = Expr::Name(alias.into());             }         }         Expr::Name(name) if !display && !bound(name, bindings) => {-            if let Some(value) = boolean_alias(name) {+            if let Some(value) = boolean_alias(locale, name) {                 *expr = Expr::Bool(value);             }         }@@ -154,13 +190,13 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut                 for pair in pairs.chunks_mut(2) {                     if let [declaration, value] = pair {                         // The new name is not in scope in its own value expression.-                        normalize(value, known_name, bindings, depth + 1, display)?;+                        normalize(value, locale, known_name, bindings, depth + 1, display)?;                         if let Expr::Name(name) = declaration {                             bindings.push(name.clone());                         }                     }                 }-                normalize(body, known_name, bindings, depth + 1, display)?;+                normalize(body, locale, known_name, bindings, depth + 1, display)?;             }             bindings.truncate(saved);         }@@ -172,40 +208,44 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut                         bindings.push(name.clone());                     }                 }-                normalize(body, known_name, bindings, depth + 1, display)?;+                normalize(body, locale, known_name, bindings, depth + 1, display)?;             }             bindings.truncate(saved);         }         Expr::Call(name, args) => {-            let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) };+            let alias = if display { locale.function_name(name) } else { locale.canonical_name(name) };             if !bound(name, bindings) && (!display || !bound(alias, bindings)) {                 *name = alias.to_string();             }+            if !display {+                // Not a localized name (an English or user-defined one): upper case as in English.+                *name = parser::normalize_function_name(name);+            }             for arg in args {-                normalize(arg, known_name, bindings, depth + 1, display)?;+                normalize(arg, locale, known_name, bindings, depth + 1, display)?;             }         }         Expr::Invoke(callee, args) => {-            normalize(callee, known_name, bindings, depth + 1, display)?;+            normalize(callee, locale, known_name, bindings, depth + 1, display)?;             for arg in args {-                normalize(arg, known_name, bindings, depth + 1, display)?;+                normalize(arg, locale, known_name, bindings, depth + 1, display)?;             }         }-        Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?,+        Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, locale, known_name, bindings, depth + 1, display)?,         Expr::Binary(..) => {             // Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting.             // Each call leaves `bindings` as it found them, so the order does not matter.             let mut node = expr;             while let Expr::Binary(_, left, right) = node {-                normalize(right, known_name, bindings, depth + 1, display)?;+                normalize(right, locale, known_name, bindings, depth + 1, display)?;                 node = left;             }-            normalize(node, known_name, bindings, depth, display)?;+            normalize(node, locale, known_name, bindings, depth, display)?;         }         Expr::Array(rows) if display => {             for row in rows {                 for child in row {-                    normalize(child, known_name, bindings, depth + 1, display)?;+                    normalize(child, locale, known_name, bindings, depth + 1, display)?;                 }             }         }diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs--- a/crates/formula/src/lexer.rs+++ b/crates/formula/src/lexer.rs@@ -49,6 +49,12 @@ pub struct LexError {     pub pos: usize, } +/// A separator inside an array constant: `\` separates columns in Spanish and rows in Turkish, and+/// would otherwise be read as part of a name.+fn is_array_separator(c: char, locale: crate::FormulaLocale) -> bool {+    c == locale.array_column_separator() || c == locale.array_row_separator()+}+ fn is_word_char(c: char) -> bool {     c.is_alphanumeric() || matches!(c, '_' | '.' | '$' | '\\' | '?') }@@ -288,6 +294,10 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To                 push(&mut out, Tok::Comma, i + 1);                 i += 1;             }+            c if array_depth > 0 && c == locale.array_row_separator() => {+                push(&mut out, Tok::Semicolon, i + 1);+                i += 1;+            }             ';' => {                 push(&mut out, Tok::Semicolon, i + 1);                 i += 1;@@ -330,7 +340,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To             }             c if is_word_char(c) => {                 let mut j = i;-                while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {+                while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && is_array_separator(c, locale))) {                     j += 1;                 }                 let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();@@ -340,7 +350,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To                 } else if ch(j) == Some(':') && {                     // Sheet1:Sheet3!A1 — look ahead for word then '!'.                     let mut k = j + 1;-                    while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {+                    while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && is_array_separator(c, locale))) {                         k += 1;                     }                     // `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span.@@ -350,7 +360,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To                     k > j + 1 && ch(k) == Some('!') && !repeats_sheet                 } {                     let mut k = j + 1;-                    while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {+                    while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && is_array_separator(c, locale))) {                         k += 1;                     }                     let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs--- a/crates/formula/src/lib.rs+++ b/crates/formula/src/lib.rs@@ -9,6 +9,7 @@ mod input; pub mod lexer; pub mod parser; pub mod printer;+mod turkish;  pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input};diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs--- a/crates/formula/src/parser.rs+++ b/crates/formula/src/parser.rs@@ -66,7 +66,7 @@ fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Resu     }     let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;     let toks = significant_spaces(toks);-    let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };+    let mut p = Parser { toks, i: 0, depth: 0, locale };     let e = p.expr(0, allow_union)?;     match p.peek() {         Tok::Eof => Ok(e),@@ -106,7 +106,8 @@ struct Parser {     toks: Vec<Token>,     i: usize,     depth: usize,-    input: bool,+    /// Entry syntax: any locale but English also accepts its TRUE/FALSE names in array constants.+    locale: crate::FormulaLocale, }  fn word_is_cols(w: &str) -> Option<(u32, bool)> {@@ -293,10 +294,13 @@ impl Parser {             }             Tok::LBrace => self.array(),             Tok::Func(name) => {-                let name = normalize_function_name(&name);+                let upper = normalize_function_name(&name);+                // Turkish entry keeps the name as typed (`indis` is `İNDİS`, not `INDIS`); the+                // alias pass upper-cases it by the locale's rules.+                let name = if self.locale == crate::FormulaLocale::Tr && upper.eq_ignore_ascii_case(&name) { name } else { upper };                 let mut args = self.args()?;                 // Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`.-                if name == "ANCHORARRAY"+                if name.eq_ignore_ascii_case("ANCHORARRAY")                     && let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice()                     && let Some(r) = args.pop()                 {@@ -434,7 +438,7 @@ impl Parser {                 },                 Tok::Text(s) => Expr::Text(Arc::from(s)),                 Tok::Bool(b) => Expr::Bool(b),-                Tok::Word(w) if self.input => crate::input::boolean_alias(&w)+                Tok::Word(w) if self.locale != crate::FormulaLocale::En => crate::input::boolean_alias(self.locale, &w)                     .map(Expr::Bool)                     .ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?,                 Tok::Error(e) => Expr::Error(e),diff --git a/crates/formula/src/turkish.rs b/crates/formula/src/turkish.rsnew file mode 100644--- /dev/null+++ b/crates/formula/src/turkish.rs@@ -0,0 +1,473 @@+//! Turkish function names for formula entry (Microsoft Excel's Turkish UI), canonical English first.+//! Sources: the Turkish names Excel ships, as published by excel-translator.de and PerfectXL (they+//! agree on 465 of GridCraft's functions; where they differ the excel-translator.de name is used) and+//! Microsoft's Turkish function reference for the dynamic-array functions added since 2019.+//! Functions Excel leaves in English in Turkish (LET, LAMBDA, BYROW, MOD, RANK…) are not listed,+//! nor those whose Turkish name could not be confirmed (MAP, UNIQUE, GROUPBY, PIVOTBY, TRIMRANGE):+//! they keep their English names, which are always accepted.+//! IMSUM is SANÇIKAR and IMSUB is SANTOPLA in Turkish Excel; the swap is Excel's and is kept so that+//! formulas typed as in Excel mean the same here.++pub(crate) const TURKISH: &[(&str, &str)] = &[+    ("ABS", "MUTLAK"),+    ("ACCRINT", "GERÇEKFAİZ"),+    ("ACCRINTM", "GERÇEKFAİZV"),+    ("ADDRESS", "ADRES"),+    ("AGGREGATE", "TOPLAMA"),+    ("AND", "VE"),+    ("ARABIC", "ARAP"),+    ("AREAS", "ALANSAY"),+    ("ARRAYTOTEXT", "DİZİMETİN"),+    ("ASIN", "ASİN"),+    ("ASINH", "ASİNH"),+    ("AVEDEV", "ORTSAP"),+    ("AVERAGE", "ORTALAMA"),+    ("AVERAGEA", "ORTALAMAA"),+    ("AVERAGEIF", "EĞERORTALAMA"),+    ("AVERAGEIFS", "ÇOKEĞERORTALAMA"),+    ("BAHTTEXT", "BAHTMETİN"),+    ("BASE", "TABAN"),+    ("BETA.DIST", "BETA.DAĞ"),+    ("BETA.INV", "BETA.TERS"),+    ("BETADIST", "BETADAĞ"),+    ("BETAINV", "BETATERS"),+    ("BINOM.DIST", "BİNOM.DAĞ"),+    ("BINOM.DIST.RANGE", "BİNOM.DAĞ.ARALIK"),+    ("BINOM.INV", "BİNOM.TERS"),+    ("BINOMDIST", "BİNOMDAĞ"),+    ("BITAND", "BİTVE"),+    ("BITLSHIFT", "BİTSOLAKAYDIR"),+    ("BITOR", "BİTVEYA"),+    ("BITRSHIFT", "BİTSAĞAKAYDIR"),+    ("BITXOR", "BİTÖZELVEYA"),+    ("CEILING", "TAVANAYUVARLA"),+    ("CEILING.MATH", "TAVANAYUVARLA.MATEMATİK"),+    ("CEILING.PRECISE", "TAVANAYUVARLA.DUYARLI"),+    ("CELL", "HÜCRE"),+    ("CHAR", "DAMGA"),+    ("CHIDIST", "KİKAREDAĞ"),+    ("CHIINV", "KİKARETERS"),+    ("CHISQ.DIST", "KİKARE.DAĞ"),+    ("CHISQ.DIST.RT", "KİKARE.DAĞ.SAĞK"),+    ("CHISQ.INV", "KİKARE.TERS"),+    ("CHISQ.INV.RT", "KİKARE.TERS.SAĞK"),+    ("CHISQ.TEST", "KİKARE.TEST"),+    ("CHITEST", "KİKARETEST"),+    ("CHOOSE", "ELEMAN"),+    ("CHOOSECOLS", "SÜTUNSEÇ"),+    ("CHOOSEROWS", "SATIRSEÇ"),+    ("CLEAN", "TEMİZ"),+    ("CODE", "KOD"),+    ("COLUMN", "SÜTUN"),+    ("COLUMNS", "SÜTUNSAY"),+    ("COMBIN", "KOMBİNASYON"),+    ("COMBINA", "KOMBİNASYONA"),+    ("COMPLEX", "KARMAŞIK"),+    ("CONCAT", "ARALIKBİRLEŞTİR"),+    ("CONCATENATE", "BİRLEŞTİR"),+    ("CONFIDENCE", "GÜVENİRLİK"),+    ("CONFIDENCE.NORM", "GÜVENİLİRLİK.NORM"),+    ("CONFIDENCE.T", "GÜVENİLİRLİK.T"),+    ("CONVERT", "ÇEVİR"),+    ("CORREL", "KORELASYON"),+    ("COUNT", "BAĞ_DEĞ_SAY"),+    ("COUNTA", "BAĞ_DEĞ_DOLU_SAY"),+    ("COUNTBLANK", "BOŞLUKSAY"),+    ("COUNTIF", "EĞERSAY"),+    ("COUNTIFS", "ÇOKEĞERSAY"),+    ("COUPDAYBS", "KUPONGÜNBD"),+    ("COUPDAYS", "KUPONGÜN"),+    ("COUPDAYSNC", "KUPONGÜNDSK"),+    ("COUPNCD", "KUPONGÜNSKT"),+    ("COUPNUM", "KUPONSAYI"),+    ("COUPPCD", "KUPONGÜNÖKT"),+    ("COVAR", "KOVARYANS"),+    ("COVARIANCE.P", "KOVARYANS.P"),+    ("COVARIANCE.S", "KOVARYANS.S"),+    ("CRITBINOM", "KRİTİKBİNOM"),+    ("CUBEKPIMEMBER", "KÜPKPIÜYESİ"),+    ("CUBEMEMBER", "KÜPÜYESİ"),+    ("CUBEMEMBERPROPERTY", "KÜPÜYEÖZELLİĞİ"),+    ("CUBERANKEDMEMBER", "DERECELİKÜPÜYESİ"),+    ("CUBESET", "KÜPKÜMESİ"),+    ("CUBESETCOUNT", "KÜPKÜMESAYISI"),+    ("CUBEVALUE", "KÜPDEĞERİ"),+    ("CUMIPMT", "TOPÖDENENFAİZ"),+    ("CUMPRINC", "TOPANAPARA"),+    ("DATE", "TARİH"),+    ("DATEDIF", "ETARİHLİ"),+    ("DATEVALUE", "TARİHSAYISI"),+    ("DAVERAGE", "VSEÇORT"),+    ("DAY", "GÜN"),+    ("DAYS", "GÜNSAY"),+    ("DAYS360", "GÜN360"),+    ("DB", "AZALANBAKİYE"),+    ("DCOUNT", "VSEÇSAY"),+    ("DCOUNTA", "VSEÇSAYDOLU"),+    ("DDB", "ÇİFTAZALANBAKİYE"),+    ("DECIMAL", "ONDALIK"),+    ("DEGREES", "DERECE"),+    ("DEVSQ", "SAPKARE"),+    ("DGET", "VAL"),+    ("DISC", "İNDİRİM"),+    ("DMAX", "VSEÇMAK"),+    ("DMIN", "VSEÇMİN"),+    ("DOLLAR", "LİRA"),+    ("DOLLARDE", "LİRAON"),+    ("DOLLARFR", "LİRAKES"),+    ("DPRODUCT", "VSEÇÇARP"),+    ("DROP", "BIRAK"),+    ("DSTDEV", "VSEÇSTDSAPMA"),+    ("DSTDEVP", "VSEÇSTDSAPMAS"),+    ("DSUM", "VSEÇTOPLA"),+    ("DURATION", "SÜRE"),+    ("DVAR", "VSEÇVAR"),+    ("DVARP", "VSEÇVARS"),+    ("EDATE", "SERİTARİH"),+    ("EFFECT", "ETKİN"),+    ("ENCODEURL", "URLKODLA"),+    ("EOMONTH", "SERİAY"),+    ("ERF", "HATAİŞLEV"),+    ("ERF.PRECISE", "HATAİŞLEV.DUYARLI"),+    ("ERFC", "TÜMHATAİŞLEV"),+    ("ERFC.PRECISE", "TÜMHATAİŞLEV.DUYARLI"),+    ("ERROR.TYPE", "HATA.TİPİ"),+    ("EVEN", "ÇİFT"),+    ("EXACT", "ÖZDEŞ"),+    ("EXP", "ÜS"),+    ("EXPAND", "GENİŞLET"),+    ("EXPON.DIST", "ÜSTEL.DAĞ"),+    ("EXPONDIST", "ÜSTELDAĞ"),+    ("F.DIST", "F.DAĞ"),+    ("F.DIST.RT", "F.DAĞ.SAĞK"),+    ("F.INV", "F.TERS"),+    ("F.INV.RT", "F.TERS.SAĞK"),+    ("FACT", "ÇARPINIM"),+    ("FACTDOUBLE", "ÇİFTFAKTÖR"),+    ("FALSE", "YANLIŞ"),+    ("FDIST", "FDAĞ"),+    ("FILTER", "FİLTRE"),+    ("FILTERXML", "XMLFİLTRELE"),+    ("FIND", "BUL"),+    ("FINDB", "BULB"),+    ("FINV", "FTERS"),+    ("FISHERINV", "FISHERTERS"),+    ("FIXED", "SAYIDÜZENLE"),+    ("FLOOR", "TABANAYUVARLA"),+    ("FLOOR.MATH", "TABANAYUVARLA.MATEMATİK"),+    ("FLOOR.PRECISE", "TABANAYUVARLA.DUYARLI"),+    ("FORECAST", "TAHMİN"),+    ("FORECAST.ETS", "TAHMİN.ETS"),+    ("FORECAST.ETS.CONFINT", "TAHMİN.ETS.GVNARAL"),+    ("FORECAST.ETS.SEASONALITY", "TAHMİN.ETS.MEVSİMSELLİK"),+    ("FORECAST.ETS.STAT", "TAHMİN.ETS.İSTAT"),+    ("FORECAST.LINEAR", "TAHMİN.DOĞRUSAL"),+    ("FORMULATEXT", "FORMÜLMETNİ"),+    ("FREQUENCY", "SIKLIK"),+    ("FV", "GD"),+    ("FVSCHEDULE", "GDPROGRAM"),+    ("GAMMA", "GAMA"),+    ("GAMMA.DIST", "GAMA.DAĞ"),+    ("GAMMA.INV", "GAMA.TERS"),+    ("GAMMADIST", "GAMADAĞ"),+    ("GAMMAINV", "GAMATERS"),+    ("GAMMALN", "GAMALN"),+    ("GAMMALN.PRECISE", "GAMALN.DUYARLI"),+    ("GCD", "OBEB"),+    ("GEOMEAN", "GEOORT"),+    ("GESTEP", "BESINIR"),+    ("GROWTH", "BÜYÜME"),+    ("HARMEAN", "HARORT"),+    ("HLOOKUP", "YATAYARA"),+    ("HOUR", "SAAT"),+    ("HSTACK", "YATAYYIĞ"),+    ("HYPERLINK", "KÖPRÜ"),+    ("HYPGEOM.DIST", "HİPERGEOM.DAĞ"),+    ("HYPGEOMDIST", "HİPERGEOMDAĞ"),+    ("IF", "EĞER"),+    ("IFERROR", "EĞERHATA"),+    ("IFNA", "EĞERYOKSA"),+    ("IFS", "ÇOKEĞER"),+    ("IMABS", "SANMUTLAK"),+    ("IMAGINARY", "SANAL"),+    ("IMARGUMENT", "SANBAĞ_DEĞİŞKEN"),+    ("IMCONJUGATE", "SANEŞLENEK"),+    ("IMCOS", "SANCOS"),+    ("IMCOSH", "SANCOSH"),+    ("IMCOT", "SANCOT"),+    ("IMCSC", "SANCSC"),+    ("IMCSCH", "SANCSCH"),+    ("IMDIV", "SANBÖL"),+    ("IMEXP", "SANÜS"),+    ("IMLN", "SANLN"),+    ("IMLOG10", "SANLOG10"),+    ("IMLOG2", "SANLOG2"),+    ("IMPOWER", "SANKUVVET"),+    ("IMPRODUCT", "SANÇARP"),+    ("IMREAL", "SANGERÇEK"),+    ("IMSEC", "SANSEC"),+    ("IMSECH", "SANSECH"),+    ("IMSIN", "SANSIN"),+    ("IMSINH", "SANSINH"),+    ("IMSQRT", "SANKAREKÖK"),+    ("IMSUB", "SANTOPLA"),+    ("IMSUM", "SANÇIKAR"),+    ("IMTAN", "SANTAN"),+    ("INDEX", "İNDİS"),+    ("INDIRECT", "DOLAYLI"),+    ("INFO", "BİLGİ"),+    ("INT", "TAMSAYI"),+    ("INTERCEPT", "KESMENOKTASI"),+    ("INTRATE", "FAİZORANI"),+    ("IPMT", "FAİZTUTARI"),+    ("IRR", "İÇ_VERİM_ORANI"),+    ("ISBLANK", "EBOŞSA"),+    ("ISERR", "EHATA"),+    ("ISERROR", "EHATALIYSA"),+    ("ISEVEN", "ÇİFTMİ"),+    ("ISFORMULA", "EFORMÜLSE"),+    ("ISLOGICAL", "EMANTIKSALSA"),+    ("ISNA", "EYOKSA"),+    ("ISNONTEXT", "EMETİNDEĞİLSE"),+    ("ISNUMBER", "ESAYIYSA"),+    ("ISO.CEILING", "ISO.TAVAN"),+    ("ISODD", "TEKMİ"),+    ("ISOMITTED", "ATLANMIŞ"),+    ("ISOWEEKNUM", "ISOHAFTASAY"),+    ("ISREF", "EREFSE"),+    ("ISTEXT", "EMETİNSE"),+    ("KURT", "BASIKLIK"),+    ("LARGE", "BÜYÜK"),+    ("LCM", "OKEK"),+    ("LEFT", "SOLDAN"),+    ("LEFTB", "SOLB"),+    ("LEN", "UZUNLUK"),+    ("LENB", "UZUNLUKB"),+    ("LINEST", "DOT"),+    ("LOGEST", "LOT"),+    ("LOGINV", "LOGTERS"),+    ("LOGNORM.DIST", "LOGNORM.DAĞ"),+    ("LOGNORM.INV", "LOGNORM.TERS"),+    ("LOGNORMDIST", "LOGNORMDAĞ"),+    ("LOOKUP", "ARA"),+    ("LOWER", "KÜÇÜKHARF"),+    ("MAKEARRAY", "DİZİOLUŞTUR"),+    ("MATCH", "KAÇINCI"),+    ("MAX", "MAK"),+    ("MAXA", "MAKA"),+    ("MAXIFS", "ÇOKEĞERMAK"),+    ("MDETERM", "DETERMİNANT"),+    ("MDURATION", "MSÜRE"),+    ("MEDIAN", "ORTANCA"),+    ("MID", "PARÇAAL"),+    ("MIDB", "ORTAB"),+    ("MIN", "MİN"),+    ("MINA", "MİNA"),+    ("MINIFS", "ÇOKEĞERMİN"),+    ("MINUTE", "DAKİKA"),+    ("MINVERSE", "DİZEY_TERS"),+    ("MIRR", "D_İÇ_VERİM_ORANI"),+    ("MMULT", "DÇARP"),+    ("MODE", "ENÇOK_OLAN"),+    ("MODE.MULT", "ENÇOK_OLAN.ÇOK"),+    ("MODE.SNGL", "ENÇOK_OLAN.TEK"),+    ("MONTH", "AY"),+    ("MROUND", "KYUVARLA"),+    ("MULTINOMIAL", "ÇOKTERİMLİ"),+    ("MUNIT", "BİRİMMATRİS"),+    ("N", "S"),+    ("NA", "YOKSAY"),+    ("NEGBINOM.DIST", "NEGBİNOM.DAĞ"),+    ("NEGBINOMDIST", "NEGBİNOMDAĞ"),+    ("NETWORKDAYS", "TAMİŞGÜNÜ"),+    ("NETWORKDAYS.INTL", "TAMİŞGÜNÜ.ULUSL"),+    ("NOMINAL", "NOMİNAL"),+    ("NORM.DIST", "NORM.DAĞ"),+    ("NORM.INV", "NORM.TERS"),+    ("NORM.S.DIST", "NORM.S.DAĞ"),+    ("NORM.S.INV", "NORM.S.TERS"),+    ("NORMDIST", "NORMDAĞ"),+    ("NORMINV", "NORMTERS"),+    ("NORMSDIST", "NORMSDAĞ"),+    ("NORMSINV", "NORMSTERS"),+    ("NOT", "DEĞİL"),+    ("NOW", "ŞİMDİ"),+    ("NPER", "TAKSİT_SAYISI"),+    ("NPV", "NBD"),+    ("NUMBERVALUE", "SAYIDEĞERİ"),+    ("ODD", "TEK"),+    ("ODDFPRICE", "TEKYDEĞER"),+    ("ODDFYIELD", "TEKYÖDEME"),+    ("ODDLPRICE", "TEKSDEĞER"),+    ("ODDLYIELD", "TEKSÖDEME"),+    ("OFFSET", "KAYDIR"),+    ("OR", "YADA"),+    ("PDURATION", "PSÜRE"),+    ("PERCENTILE", "YÜZDEBİRLİK"),+    ("PERCENTILE.EXC", "YÜZDEBİRLİK.HRC"),+    ("PERCENTILE.INC", "YÜZDEBİRLİK.DHL"),+    ("PERCENTRANK", "YÜZDERANK"),+    ("PERCENTRANK.EXC", "YÜZDERANK.HRC"),+    ("PERCENTRANK.INC", "YÜZDERANK.DHL"),+    ("PERMUT", "PERMÜTASYON"),+    ("PERMUTATIONA", "PERMÜTASYONA"),+    ("PHONETIC", "SES"),+    ("PI", "Pİ"),+    ("PMT", "DEVRESEL_ÖDEME"),+    ("POISSON.DIST", "POISSON.DAĞ"),+    ("POWER", "KUVVET"),+    ("PPMT", "ANA_PARA_ÖDEMESİ"),+    ("PRICE", "DEĞER"),+    ("PRICEDISC", "DEĞERİND"),+    ("PRICEMAT", "DEĞERVADE"),+    ("PROB", "OLASILIK"),+    ("PRODUCT", "ÇARPIM"),+    ("PROPER", "YAZIM.DÜZENİ"),+    ("PV", "BD"),+    ("QUARTILE", "DÖRTTEBİRLİK"),+    ("QUARTILE.EXC", "DÖRTTEBİRLİK.HRC"),+    ("QUARTILE.INC", "DÖRTTEBİRLİK.DHL"),+    ("QUOTIENT", "BÖLÜM"),+    ("RADIANS", "RADYAN"),+    ("RAND", "S_SAYI_ÜRET"),+    ("RANDARRAY", "RASGDİZİ"),+    ("RANDBETWEEN", "RASTGELEARADA"),+    ("RANK.AVG", "RANK.ORT"),+    ("RANK.EQ", "RANK.EŞİT"),+    ("RATE", "FAİZ_ORANI"),+    ("RECEIVED", "GETİRİ"),+    ("REGEXEXTRACT", "NORMİFADEAYIKLA"),+    ("REGEXREPLACE", "NORMİFADEDEĞİŞTİR"),+    ("REGEXTEST", "NORMİFADESINA"),+    ("REPLACE", "DEĞİŞTİR"),+    ("REPLACEB", "DEĞİŞTİRB"),+    ("REPT", "YİNELE"),+    ("RIGHT", "SAĞDAN"),+    ("RIGHTB", "SAĞB"),+    ("ROMAN", "ROMEN"),+    ("ROUND", "YUVARLA"),+    ("ROUNDDOWN", "AŞAĞIYUVARLA"),+    ("ROUNDUP", "YUKARIYUVARLA"),+    ("ROW", "SATIR"),+    ("ROWS", "SATIRSAY"),+    ("RRI", "GERÇEKLEŞENYATIRIMGETİRİSİ"),+    ("RSQ", "RKARE"),+    ("SEARCH", "MBUL"),+    ("SEARCHB", "ARAB"),+    ("SECOND", "SANİYE"),+    ("SEQUENCE", "SIRALI"),+    ("SERIESSUM", "SERİTOPLA"),+    ("SHEET", "SAYFA"),+    ("SHEETS", "SAYFALAR"),+    ("SIGN", "İŞARET"),+    ("SIN", "SİN"),+    ("SINH", "SİNH"),+    ("SKEW", "ÇARPIKLIK"),+    ("SKEW.P", "ÇARPIKLIK.P"),+    ("SLN", "DA"),+    ("SLOPE", "EĞİM"),+    ("SMALL", "KÜÇÜK"),+    ("SORT", "SIRALA"),+    ("SORTBY", "SIRALAÖLÇÜT"),+    ("SQRT", "KAREKÖK"),+    ("SQRTPI", "KAREKÖKPİ"),+    ("STANDARDIZE", "STANDARTLAŞTIRMA"),+    ("STDEV", "STDSAPMA"),+    ("STDEV.P", "STDSAPMA.P"),+    ("STDEV.S", "STDSAPMA.S"),+    ("STDEVA", "STDSAPMAA"),+    ("STDEVP", "STDSAPMAS"),+    ("STDEVPA", "STDSAPMASA"),+    ("STEYX", "STHYX"),+    ("SUBSTITUTE", "YERİNEKOY"),+    ("SUBTOTAL", "ALTTOPLAM"),+    ("SUM", "TOPLA"),+    ("SUMIF", "ETOPLA"),+    ("SUMIFS", "ÇOKETOPLA"),+    ("SUMPRODUCT", "TOPLA.ÇARPIM"),+    ("SUMSQ", "TOPKARE"),+    ("SUMX2MY2", "TOPX2EY2"),+    ("SUMX2PY2", "TOPX2AY2"),+    ("SUMXMY2", "TOPXEY2"),+    ("SWITCH", "İLKEŞLEŞEN"),+    ("SYD", "YAT"),+    ("T", "M"),+    ("T.DIST", "T.DAĞ"),+    ("T.DIST.2T", "T.DAĞ.2K"),+    ("T.DIST.RT", "T.DAĞ.SAĞK"),+    ("T.INV", "T.TERS"),+    ("T.INV.2T", "T.TERS.2K"),+    ("TAKE", "AL"),+    ("TBILLEQ", "HTAHEŞ"),+    ("TBILLPRICE", "HTAHDEĞER"),+    ("TBILLYIELD", "HTAHÖDEME"),+    ("TDIST", "TDAĞ"),+    ("TEXT", "METNEÇEVİR"),+    ("TEXTAFTER", "SONRAKİMETİN"),+    ("TEXTBEFORE", "ÖNCEKİMETİN"),+    ("TEXTJOIN", "METİNBİRLEŞTİR"),+    ("TEXTSPLIT", "METİNBÖL"),+    ("TIME", "ZAMAN"),+    ("TIMEVALUE", "ZAMANSAYISI"),+    ("TINV", "TTERS"),+    ("TOCOL", "SÜTUNA"),+    ("TODAY", "BUGÜN"),+    ("TOROW", "SATIRA"),+    ("TRANSPOSE", "DEVRİK_DÖNÜŞÜM"),+    ("TREND", "EĞİLİM"),+    ("TRIM", "KIRP"),+    ("TRIMMEAN", "KIRPORTALAMA"),+    ("TRUE", "DOĞRU"),+    ("TRUNC", "NSAT"),+    ("TYPE", "TÜR"),+    ("UNICHAR", "UNICODEKARAKTERİ"),+    ("UPPER", "BÜYÜKHARF"),+    ("USDOLLAR", "ABDDOLAR"),+    ("VALUE", "SAYIYAÇEVİR"),+    ("VALUETOTEXT", "DEĞERMETİN"),+    ("VARP", "VARS"),+    ("VARPA", "VARSA"),+    ("VDB", "DAB"),+    ("VLOOKUP", "DÜŞEYARA"),+    ("VSTACK", "DÜŞEYYIĞ"),+    ("WEBSERVICE", "WEBHİZMETİ"),+    ("WEEKDAY", "HAFTANINGÜNÜ"),+    ("WEEKNUM", "HAFTASAY"),+    ("WEIBULL.DIST", "WEIBULL.DAĞ"),+    ("WORKDAY", "İŞGÜNÜ"),+    ("WORKDAY.INTL", "İŞGÜNÜ.ULUSL"),+    ("WRAPCOLS", "SÜTUNSAR"),+    ("WRAPROWS", "SATIRSAR"),+    ("XIRR", "AİÇVERİMORANI"),+    ("XLOOKUP", "ÇAPRAZARA"),+    ("XMATCH", "ÇAPRAZEŞLEŞTİR"),+    ("XNPV", "ANBD"),+    ("XOR", "ÖZELVEYA"),+    ("YEAR", "YIL"),+    ("YEARFRAC", "YILORAN"),+    ("YIELD", "ÖDEME"),+    ("YIELDDISC", "ÖDEMEİND"),+    ("YIELDMAT", "ÖDEMEVADE"),+];++#[cfg(test)]+mod tests {+    use super::TURKISH;++    /// Each name appears once on each side, in upper case, as a valid function name; a Turkish+    /// name is never another function's English name (it would change what that name calls).+    #[test]+    fn table_is_one_to_one_and_well_formed() {+        let mut english = std::collections::HashSet::new();+        let mut turkish = std::collections::HashSet::new();+        for (en, tr) in TURKISH {+            assert!(english.insert(*en), "{en} twice");+            assert!(turkish.insert(*tr), "{tr} twice");+            assert_ne!(en, tr, "{en}: same as English, leave it out");+            assert!(tr.chars().all(|c| c.is_uppercase() || c.is_ascii_digit() || matches!(c, '.' | '_')), "{tr}");+        }+        for (en, tr) in TURKISH {+            assert!(!english.contains(tr), "{en}: {tr} is also an English name");+        }+    }+}diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs--- a/crates/formula/tests/it/main.rs+++ b/crates/formula/tests/it/main.rs@@ -2,3 +2,4 @@  mod printer_roundtrip; mod spanish_input;+mod turkish_input;diff --git a/crates/formula/tests/it/turkish_input.rs b/crates/formula/tests/it/turkish_input.rsnew file mode 100644--- /dev/null+++ b/crates/formula/tests/it/turkish_input.rs@@ -0,0 +1,108 @@+use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input};++const TR: FormulaLocale = FormulaLocale::Tr;++fn input(src: &str) -> String {+    print(&parse_input(src, TR, |_| false).unwrap())+}++#[test]+fn turkish_function_names_and_separators_as_in_excel() {+    for (src, expected) in [+        ("=topla(A1:A10)", "SUM(A1:A10)"),+        ("EĞER(A1>0;\"Var\";\"Yok\")", "IF(A1>0,\"Var\",\"Yok\")"),+        ("eğer(A1>0;1;2)", "IF(A1>0,1,2)"),+        ("DÜŞEYARA(A2;D:E;2;YANLIŞ)", "VLOOKUP(A2,D:E,2,FALSE)"),+        ("ETOPLA(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"),+        ("EĞERSAY(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"),+        ("ÇOKETOPLA(C:C;A:A;\"x\";B:B;\">5\")", "SUMIFS(C:C,A:A,\"x\",B:B,\">5\")"),+        ("BAĞ_DEĞ_SAY(A1:A9)", "COUNT(A1:A9)"),+        ("DEVRESEL_ÖDEME(0,08/12;10;10000)", "PMT(0.08/12,10,10000)"),+        ("İNDİS(A1:B5;KAÇINCI(\"x\";A1:A5;0);2)", "INDEX(A1:B5,MATCH(\"x\",A1:A5,0),2)"),+        ("indis(A1:B5;2;1)", "INDEX(A1:B5,2,1)"),+        ("ÇAPRAZARA(1;A:A;B:B)", "XLOOKUP(1,A:A,B:B)"),+        ("TOPLA.ÇARPIM(A1:A3;B1:B3)", "SUMPRODUCT(A1:A3,B1:B3)"),+        ("YUVARLA(1,2345;2)", "ROUND(1.2345,2)"),+        ("BUGÜN()", "TODAY()"),+        ("satır()", "ROW()"),+        ("MİN(1;2)+min(3;4)", "MIN(1,2)+MIN(3,4)"),+        // Excel's own Turkish names are kept, even where they look swapped.+        ("SANÇIKAR(\"1+i\";\"2\")", "IMSUM(\"1+i\",\"2\")"),+        ("SANTOPLA(\"1+i\";\"2\")", "IMSUB(\"1+i\",\"2\")"),+        ("=doğru", "TRUE"),+        ("=YANLIŞ", "FALSE"),+        ("DOĞRU()", "TRUE()"),+        // English names are always accepted, as are functions Turkish Excel leaves in English.+        ("SUM(1;2)+MOD(7;2)", "SUM(1,2)+MOD(7,2)"),+        ("LET(x;2;x*3)", "LET(x,2,x*3)"),+        ("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"),+    ] {+        assert_eq!(input(src), expected, "{src}");+    }+}++#[test]+fn turkish_letters_follow_turkish_case_rules() {+    // `i` upper-cases to `İ` and `ı` to `I`, as Turkish Excel reads them.+    assert_eq!(TR.upper("eğer"), "EĞER");+    assert_eq!(TR.upper("indis"), "İNDİS");+    assert_eq!(TR.upper("satır"), "SATIR");+    assert_eq!(TR.canonical_name("min"), "MIN");+    assert_eq!(TR.canonical_name("MİN"), "MIN");+    assert_eq!(TR.canonical_name("Düşeyara"), "VLOOKUP");+    // Not a Turkish name: the text is left as typed (an English or user-defined name).+    assert_eq!(TR.canonical_name("MIN"), "MIN");+    assert_eq!(TR.function_name("vlookup"), "DÜŞEYARA");+    assert_eq!(TR.function_name("LAMBDA"), "LAMBDA");+}++#[test]+fn turkish_display_round_trips() {+    for english in [+        "IF(TRUE,SUM(1.5,2.25),0)",+        "VLOOKUP(A2,Sheet2!A:C,3,FALSE)",+        "IFERROR(INDEX(A1:A9,MATCH(B1,C1:C9,0)),\"\")",+        "SUM({1.5,2;3,4})+SUM((A1:A3,C1:C3))",+        "{TRUE,FALSE;FALSE,TRUE}",+        r#"IF(A1="a,b;c\d",CONCAT("1.5",",",";"),SUM(0.25,A1:A3 B2))"#,+    ] {+        let expr = parse(english).unwrap();+        let shown = print_input(&expr, TR, |_| false).unwrap();+        assert_eq!(print(&parse_input(&shown, TR, |_| false).unwrap()), print(&expr), "{english} shown as {shown}");+    }+    let expr = parse("IF(TRUE,SUM(1.5,2.25),VLOOKUP(1,A:B,2,FALSE))").unwrap();+    assert_eq!(print_input(&expr, TR, |_| false).unwrap(), "EĞER(DOĞRU;TOPLA(1,5;2,25);DÜŞEYARA(1;A:B;2;YANLIŞ))");+}++#[test]+fn turkish_array_constants_and_references() {+    // Turkish Excel: `;` between the columns of an array constant, `\` between its rows.+    assert_eq!(input("TOPLA({1;2\\3;4};5)"), "SUM({1,2;3,4},5)");+    assert_eq!(input("{DOĞRU;YANLIŞ\\TRUE;FALSE}"), "{TRUE,FALSE;TRUE,FALSE}");+    assert_eq!(print_input(&parse("{1.5,2;3,4}").unwrap(), TR, |_| false).unwrap(), "{1,5;2\\3;4}");+    for (src, expected) in [+        ("TOPLA((A1:A3;C1:C3))", "SUM((A1:A3,C1:C3))"),+        ("\"TOPLA;YANLIŞ\"&\"a\"\"b\"", "\"TOPLA;YANLIŞ\"&\"a\"\"b\""),+        ("'TOPLA;YANLIŞ'!A1+TOPLA!B2", "'TOPLA;YANLIŞ'!A1+TOPLA!B2"),+        ("Tablo1[EĞER]+YANLIŞ[TOPLA]", "Tablo1[EĞER]+YANLIŞ[TOPLA]"),+    ] {+        assert_eq!(input(src), expected, "{src}");+    }+    for bad in ["{TOPLA(A1)}", "{YANLIŞ\\}", "TOPLA(1.5;2)"] {+        assert!(parse_input(bad, TR, |_| false).is_err(), "{bad}");+    }+}++#[test]+fn turkish_input_preserves_defined_and_local_names() {+    let known = |name: &str| ["TOPLA", "YANLIŞ"].iter().any(|n| gridcraft_formula::same_name(n, name));+    let expr = parse_input("TOPLA(2;3)+YANLIŞ+EĞER(DOĞRU;4;5)", TR, known).unwrap();+    assert_eq!(print(&expr), "TOPLA(2,3)+YANLIŞ+IF(TRUE,4,5)");+    for (src, expected) in [+        ("LET(YANLIŞ;7;YANLIŞ)", "LET(YANLIŞ,7,YANLIŞ)"),+        ("LET(TOPLA;LAMBDA(x;x+1);TOPLA(2))", "LET(TOPLA,LAMBDA(x,x+1),TOPLA(2))"),+        ("LAMBDA(EĞER;EĞER+1)(2)", "LAMBDA(EĞER,EĞER+1)(2)"),+    ] {+        assert_eq!(input(src), expected, "{src}");+    }+}diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs--- a/crates/ui-egui/src/editor.rs+++ b/crates/ui-egui/src/editor.rs@@ -162,11 +162,12 @@ impl EditState {         }         let before = self.text.get(..self.byte_caret()).unwrap_or("");         let word: String =-            before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();+            before.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();         if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) {             return;         }-        let u = word.to_ascii_uppercase();+        // Turkish upper case: `eğer` → `EĞER`, `i` → `İ`.+        let u = self.locale.upper(&word);         // Not after a column letter of a reference like A1 (digits follow) – fine either way.         self.autocomplete = names.iter().filter(|n| n.starts_with(&u)).take(12).cloned().collect();         if self.autocomplete.len() == 1 && self.autocomplete[0] == u {@@ -180,7 +181,7 @@ impl EditState {         let Some(name) = self.autocomplete.get(self.ac_index).cloned() else { return false };         let caret_b = self.byte_caret();         let before = self.text.get(..caret_b).unwrap_or("");-        let wlen: usize = before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').map(char::len_utf8).sum();+        let wlen: usize = before.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').map(char::len_utf8).sum();         let start = caret_b - wlen;         let (Some(a), Some(b)) = (self.text.get(..start), self.text.get(caret_b..)) else { return false };         self.text = format!("{a}{name}({b}");@@ -210,7 +211,7 @@ impl EditState {         }         let head = before.strip_suffix('(').unwrap_or(before);         let name: String =-            head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();+            head.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();         if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {             return false;         }@@ -258,7 +259,7 @@ impl EditState {                         let name: String = chars[..i]                             .iter()                             .rev()-                            .take_while(|c| c.is_ascii_alphanumeric() || **c == '.' || **c == '_')+                            .take_while(|c| c.is_alphanumeric() || **c == '.' || **c == '_')                             .collect::<String>()                             .chars()                             .rev()@@ -266,7 +267,7 @@ impl EditState {                         if name.is_empty() {                             return None;                         }-                        return Some((name.to_ascii_uppercase(), arg));+                        return Some((self.locale.upper(&name), arg));                     }                     depth -= 1;                 }diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -186,7 +186,12 @@ impl Language {     }      pub fn formula_locale(self) -> gridcraft_engine::formula::FormulaLocale {-        if self == Self::Es { gridcraft_engine::formula::FormulaLocale::Es } else { gridcraft_engine::formula::FormulaLocale::En }+        use gridcraft_engine::formula::FormulaLocale;+        match self {+            Self::Es => FormulaLocale::Es,+            Self::Tr => FormulaLocale::Tr,+            _ => FormulaLocale::En,+        }     }      /// Translates an interface label; untranslated labels fall back to English.diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs--- a/crates/ui-egui/tests/it/formula_locale.rs+++ b/crates/ui-egui/tests/it/formula_locale.rs@@ -220,14 +220,18 @@ fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { }  #[test]-fn turkish_interface_retains_its_canonical_formula_fallback() {+fn turkish_interface_enters_and_shows_turkish_formulas() {     let mut app = app();     app.run("app.language.set", json!({"code":"tr-TR"})).unwrap();     assert_eq!(app.ui.language, Language::Tr);-    assert!(enter(&mut app, "=SUM(1.5,2.25)"));+    // Typed as in Turkish Excel (Turkish case rules, `;` arguments, decimal comma), stored in English.+    assert!(enter(&mut app, "=eğer(DOĞRU;topla(1,5;2,25);0)"));+    assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)");     app.begin_edit(None, true);-    assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");-    assert!(!enter(&mut app, "=TOPLA(1;2)"));+    assert_eq!(app.editor.as_ref().unwrap().text, "=EĞER(DOĞRU;TOPLA(1,5;2,25);0)");+    assert!(enter(&mut app, "=indis({10;20;30};1;2)"));+    assert_eq!(formula(&app), "INDEX({10,20,30},1,2)");+    assert!(!enter(&mut app, "=TOPLA(1.5;2)"));     let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();     assert_eq!(restored.language, Language::Tr); }diff --git a/docs/formula-input.md b/docs/formula-input.md--- a/docs/formula-input.md+++ b/docs/formula-input.md@@ -1,8 +1,9 @@ # Formula language  View → Interface language is the existing, persisted language preference. Choose **Español**-for Spanish formula entry and display. On first run the preference follows the system language.-English and the other interface languages currently use the English formula table and separators.+for Spanish or **Türkçe** for Turkish formula entry and display. On first run the preference+follows the system language. English and the other interface languages currently use the English+formula table and separators.  | Spanish | Canonical English | | --- | --- |@@ -27,6 +28,19 @@ without a Spanish entry retain their English names/help. General interface label fall back to English where Spanish translations are not provided. An edit captures its language when it starts so switching the preference cannot reinterpret unfinished input. +## Turkish++Turkish uses Microsoft Excel's Turkish function names for 439 functions+(`crates/formula/src/turkish.rs`): `TOPLA`, `EĞER`, `DÜŞEYARA`, `ETOPLA`, `EĞERSAY`,+`BAĞ_DEĞ_SAY`, `İNDİS`, `KAÇINCI`, `DEVRESEL_ÖDEME`, `ÇAPRAZARA`… and `DOĞRU` / `YANLIŞ` for+TRUE and FALSE. Names follow Turkish case rules: `eğer` is `EĞER`, `indis` is `İNDİS` (`i` ↔ `İ`,+`ı` ↔ `I`). Arguments are separated by `;`, decimals use a comma, and array constants separate+columns with `;` and rows with `\`, as in Turkish Excel: `=TOPLA({1;2\3;4};1,5)` is 11.5.+Functions Turkish Excel leaves in English (`LET`, `LAMBDA`, `BYROW`, `MOD`, `RANK`…) and those+whose Turkish name is not confirmed yet keep their English names; English names are always+accepted. Excel's own Turkish names are kept even where they look swapped (`IMSUM` is+`SANÇIKAR`, `IMSUB` is `SANTOPLA`), so a formula typed as in Turkish Excel means the same here.+ Workbook/sheet names, table names, LET bindings and LAMBDA parameters take precedence over aliases. Text, sheet names and table column names are unchanged. When a localized spelling would collide with a name, displayed formulas retain the canonical spelling to preserve meaning.diff --git a/docs/localization-parity.md b/docs/localization-parity.md--- a/docs/localization-parity.md+++ b/docs/localization-parity.md@@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats,@@ -110,6 +110,7 @@ language (none so far) and of localized function names.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Turkish formula entry (`FormulaLocale::Tr`): Excel's Turkish names for 439 functions (`crates/formula/src/turkish.rs`), `DOĞRU`/`YANLIŞ`, `;` arguments, decimal comma, `;` columns and `\` rows in array constants; Turkish case rules (`indis` = `İNDİS`) in parsing, autocomplete, argument hints and Ctrl+Shift+A. English names stay accepted | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |