Skip to main content

runmat_runtime/builtins/strings/text_analytics/
documents.rs

1//! Core Text Analytics document and bag-of-words compatibility objects.
2
3use std::cell::Cell;
4use std::collections::{BTreeMap, HashMap, HashSet};
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use runmat_builtins::{
9    Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11    CellArray, ClassDef, LogicalArray, ObjectInstance, PropertyDef, ResolveContext, StringArray,
12    Tensor, Type, Value,
13};
14use runmat_macros::runtime_builtin;
15
16use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
17use crate::builtins::strings::core::compat::scalar_text;
18use crate::builtins::strings::text_analytics::stopwords::{
19    stop_words_for_language, StopWordsLanguage,
20};
21use crate::builtins::table::table_variables;
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult};
23
24pub const TOKENIZED_DOCUMENT_CLASS: &str = "tokenizedDocument";
25pub const BAG_OF_WORDS_CLASS: &str = "bagOfWords";
26const MAX_DENSE_BAG_COUNT_CELLS: usize = 50_000_000;
27
28thread_local! {
29    static TOKENIZED_DOCUMENT_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
30    static BAG_OF_WORDS_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
31}
32
33const OUT_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34    name: "documents",
35    ty: BuiltinParamType::Any,
36    arity: BuiltinParamArity::Required,
37    default: None,
38    description: "Tokenized document object.",
39}];
40
41const OUT_BAG: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
42    name: "bag",
43    ty: BuiltinParamType::Any,
44    arity: BuiltinParamArity::Required,
45    default: None,
46    description: "Bag-of-words model object.",
47}];
48
49const OUT_DOCUMENTS_OR_BAG: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
50    name: "newDocumentsOrBag",
51    ty: BuiltinParamType::Any,
52    arity: BuiltinParamArity::Required,
53    default: None,
54    description: "Filtered tokenizedDocument or bagOfWords object.",
55}];
56
57const OUT_DOCUMENTS_FILTERED: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58    name: "newDocuments",
59    ty: BuiltinParamType::Any,
60    arity: BuiltinParamArity::Required,
61    default: None,
62    description: "Filtered tokenizedDocument object.",
63}];
64
65const IN_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66    name: "str",
67    ty: BuiltinParamType::Any,
68    arity: BuiltinParamArity::Required,
69    default: None,
70    description: "Input text or pre-tokenized words.",
71}];
72
73const IN_TEXT_REST: [BuiltinParamDescriptor; 2] = [
74    BuiltinParamDescriptor {
75        name: "str",
76        ty: BuiltinParamType::Any,
77        arity: BuiltinParamArity::Required,
78        default: None,
79        description: "Input text or pre-tokenized words.",
80    },
81    BuiltinParamDescriptor {
82        name: "NameValue",
83        ty: BuiltinParamType::Any,
84        arity: BuiltinParamArity::Variadic,
85        default: None,
86        description:
87            "Name-value options: TokenizeMethod, Language, DetectPatterns, TopLevelDomains, CustomTokens, RegularExpressions.",
88    },
89];
90
91const IN_DOCUMENTS_OR_WORDS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
92    name: "documentsOrWords",
93    ty: BuiltinParamType::Any,
94    arity: BuiltinParamArity::Required,
95    default: None,
96    description: "Tokenized documents, word vector, or unique vocabulary.",
97}];
98
99const IN_WORDS_COUNTS: [BuiltinParamDescriptor; 2] = [
100    BuiltinParamDescriptor {
101        name: "uniqueWords",
102        ty: BuiltinParamType::Any,
103        arity: BuiltinParamArity::Required,
104        default: None,
105        description: "Unique words.",
106    },
107    BuiltinParamDescriptor {
108        name: "counts",
109        ty: BuiltinParamType::Any,
110        arity: BuiltinParamArity::Required,
111        default: None,
112        description: "Word counts per document.",
113    },
114];
115
116const IN_REMOVE_SHORT: [BuiltinParamDescriptor; 2] = [
117    BuiltinParamDescriptor {
118        name: "documentsOrBag",
119        ty: BuiltinParamType::Any,
120        arity: BuiltinParamArity::Required,
121        default: None,
122        description: "tokenizedDocument or bagOfWords object.",
123    },
124    BuiltinParamDescriptor {
125        name: "len",
126        ty: BuiltinParamType::NumericScalar,
127        arity: BuiltinParamArity::Required,
128        default: None,
129        description: "Maximum word length to remove.",
130    },
131];
132
133const IN_REMOVE_LONG: [BuiltinParamDescriptor; 2] = [
134    BuiltinParamDescriptor {
135        name: "documentsOrBag",
136        ty: BuiltinParamType::Any,
137        arity: BuiltinParamArity::Required,
138        default: None,
139        description: "tokenizedDocument or bagOfWords object.",
140    },
141    BuiltinParamDescriptor {
142        name: "len",
143        ty: BuiltinParamType::NumericScalar,
144        arity: BuiltinParamArity::Required,
145        default: None,
146        description: "Minimum word length to remove.",
147    },
148];
149
150const IN_REMOVE_WORDS: [BuiltinParamDescriptor; 3] = [
151    BuiltinParamDescriptor {
152        name: "documentsOrBag",
153        ty: BuiltinParamType::Any,
154        arity: BuiltinParamArity::Required,
155        default: None,
156        description: "tokenizedDocument or bagOfWords object.",
157    },
158    BuiltinParamDescriptor {
159        name: "wordsOrIdx",
160        ty: BuiltinParamType::Any,
161        arity: BuiltinParamArity::Required,
162        default: None,
163        description: "Words to remove or indices into the object's Vocabulary.",
164    },
165    BuiltinParamDescriptor {
166        name: "NameValue",
167        ty: BuiltinParamType::Any,
168        arity: BuiltinParamArity::Variadic,
169        default: None,
170        description: "Name-value options: IgnoreCase.",
171    },
172];
173
174const IN_REMOVE_STOP: [BuiltinParamDescriptor; 2] = [
175    BuiltinParamDescriptor {
176        name: "documents",
177        ty: BuiltinParamType::Any,
178        arity: BuiltinParamArity::Required,
179        default: None,
180        description: "tokenizedDocument object.",
181    },
182    BuiltinParamDescriptor {
183        name: "NameValue",
184        ty: BuiltinParamType::Any,
185        arity: BuiltinParamArity::Variadic,
186        default: None,
187        description: "Name-value options: IgnoreCase.",
188    },
189];
190
191const IN_REMOVE_STOP_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
192    name: "documents",
193    ty: BuiltinParamType::Any,
194    arity: BuiltinParamArity::Required,
195    default: None,
196    description: "tokenizedDocument object.",
197}];
198
199const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
200    code: "RM.TEXT_ANALYTICS_DOCUMENTS.INVALID_INPUT",
201    identifier: Some("RunMat:textAnalyticsDocuments:InvalidInput"),
202    when: "Inputs do not match a supported Text Analytics document or model helper form.",
203    message: "Text Analytics document helper received invalid input",
204};
205
206const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
207
208const ERROR_REMOVE_LONG_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
209    code: "RM.REMOVELONGWORDS.INVALID_INPUT",
210    identifier: Some("RunMat:removeLongWords:InvalidInput"),
211    when: "Inputs do not match a supported removeLongWords form.",
212    message: "removeLongWords: invalid input",
213};
214
215const REMOVE_LONG_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] =
216    [ERROR_REMOVE_LONG_WORDS_INVALID_INPUT];
217
218const ERROR_REMOVE_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
219    code: "RM.REMOVEWORDS.INVALID_INPUT",
220    identifier: Some("RunMat:removeWords:InvalidInput"),
221    when: "Inputs do not match a supported removeWords form.",
222    message: "removeWords: invalid input",
223};
224
225const REMOVE_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_REMOVE_WORDS_INVALID_INPUT];
226
227const ERROR_REMOVE_STOP_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
228    code: "RM.REMOVESTOPWORDS.INVALID_INPUT",
229    identifier: Some("RunMat:removeStopWords:InvalidInput"),
230    when: "Inputs do not match a supported removeStopWords form.",
231    message: "removeStopWords: invalid input",
232};
233
234const REMOVE_STOP_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] =
235    [ERROR_REMOVE_STOP_WORDS_INVALID_INPUT];
236
237const ERROR_TOKEN_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
238    code: "RM.TOKENDETAILS.INVALID_INPUT",
239    identifier: Some("RunMat:tokenDetails:InvalidInput"),
240    when: "Inputs do not match a supported tokenDetails form.",
241    message: "tokenDetails received invalid input",
242};
243const ERROR_ADD_TYPE_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
244    code: "RM.ADDTYPEDETAILS.INVALID_INPUT",
245    identifier: Some("RunMat:addTypeDetails:InvalidInput"),
246    when: "Inputs do not match a supported addTypeDetails form.",
247    message: "addTypeDetails received invalid input",
248};
249const ERROR_ADD_SENTENCE_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
250    code: "RM.ADDSENTENCEDETAILS.INVALID_INPUT",
251    identifier: Some("RunMat:addSentenceDetails:InvalidInput"),
252    when: "Inputs do not match a supported addSentenceDetails form.",
253    message: "addSentenceDetails received invalid input",
254};
255const ERROR_ADD_LEMMA_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
256    code: "RM.ADDLEMMADETAILS.INVALID_INPUT",
257    identifier: Some("RunMat:addLemmaDetails:InvalidInput"),
258    when: "Inputs do not match a supported addLemmaDetails form.",
259    message: "addLemmaDetails received invalid input",
260};
261const ERROR_ADD_PART_OF_SPEECH_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor =
262    BuiltinErrorDescriptor {
263        code: "RM.ADDPARTOFSPEECHDETAILS.INVALID_INPUT",
264        identifier: Some("RunMat:addPartOfSpeechDetails:InvalidInput"),
265        when: "Inputs do not match a supported addPartOfSpeechDetails form.",
266        message: "addPartOfSpeechDetails received invalid input",
267    };
268const ERROR_ADD_ENTITY_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
269    code: "RM.ADDENTITYDETAILS.INVALID_INPUT",
270    identifier: Some("RunMat:addEntityDetails:InvalidInput"),
271    when: "Inputs do not match a supported addEntityDetails form.",
272    message: "addEntityDetails received invalid input",
273};
274const ERROR_ADD_DEPENDENCY_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
275    code: "RM.ADDDEPENDENCYDETAILS.INVALID_INPUT",
276    identifier: Some("RunMat:addDependencyDetails:InvalidInput"),
277    when: "Inputs do not match a supported addDependencyDetails form.",
278    message: "addDependencyDetails received invalid input",
279};
280const ERROR_VADER_SENTIMENT_SCORES_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
281    code: "RM.VADERSENTIMENTSCORES.INVALID_INPUT",
282    identifier: Some("RunMat:vaderSentimentScores:InvalidInput"),
283    when: "Inputs do not match a supported vaderSentimentScores form.",
284    message: "vaderSentimentScores received invalid input",
285};
286
287pub const TOKENIZED_DOCUMENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
288    signatures: &[
289        BuiltinSignatureDescriptor {
290            label: "documents = tokenizedDocument",
291            inputs: &[],
292            outputs: &OUT_DOCUMENTS,
293        },
294        BuiltinSignatureDescriptor {
295            label: "documents = tokenizedDocument(str)",
296            inputs: &IN_TEXT,
297            outputs: &OUT_DOCUMENTS,
298        },
299        BuiltinSignatureDescriptor {
300            label: "documents = tokenizedDocument(str, Name, Value, ...)",
301            inputs: &IN_TEXT_REST,
302            outputs: &OUT_DOCUMENTS,
303        },
304    ],
305    output_mode: BuiltinOutputMode::Fixed,
306    completion_policy: BuiltinCompletionPolicy::Public,
307    errors: &ERRORS,
308};
309
310pub const BAG_OF_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
311    signatures: &[
312        BuiltinSignatureDescriptor {
313            label: "bag = bagOfWords",
314            inputs: &[],
315            outputs: &OUT_BAG,
316        },
317        BuiltinSignatureDescriptor {
318            label: "bag = bagOfWords(documents)",
319            inputs: &IN_DOCUMENTS_OR_WORDS,
320            outputs: &OUT_BAG,
321        },
322        BuiltinSignatureDescriptor {
323            label: "bag = bagOfWords(uniqueWords, counts)",
324            inputs: &IN_WORDS_COUNTS,
325            outputs: &OUT_BAG,
326        },
327    ],
328    output_mode: BuiltinOutputMode::Fixed,
329    completion_policy: BuiltinCompletionPolicy::Public,
330    errors: &ERRORS,
331};
332
333pub const REMOVE_SHORT_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
334    signatures: &[BuiltinSignatureDescriptor {
335        label: "newDocumentsOrBag = removeShortWords(documentsOrBag, len)",
336        inputs: &IN_REMOVE_SHORT,
337        outputs: &OUT_DOCUMENTS_OR_BAG,
338    }],
339    output_mode: BuiltinOutputMode::Fixed,
340    completion_policy: BuiltinCompletionPolicy::Public,
341    errors: &ERRORS,
342};
343
344pub const REMOVE_LONG_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
345    signatures: &[BuiltinSignatureDescriptor {
346        label: "newDocumentsOrBag = removeLongWords(documentsOrBag, len)",
347        inputs: &IN_REMOVE_LONG,
348        outputs: &OUT_DOCUMENTS_OR_BAG,
349    }],
350    output_mode: BuiltinOutputMode::Fixed,
351    completion_policy: BuiltinCompletionPolicy::Public,
352    errors: &REMOVE_LONG_WORDS_ERRORS,
353};
354
355pub const REMOVE_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
356    signatures: &[
357        BuiltinSignatureDescriptor {
358            label: "newDocumentsOrBag = removeWords(documentsOrBag, words)",
359            inputs: &IN_REMOVE_WORDS,
360            outputs: &OUT_DOCUMENTS_OR_BAG,
361        },
362        BuiltinSignatureDescriptor {
363            label: "newDocumentsOrBag = removeWords(documentsOrBag, idx)",
364            inputs: &IN_REMOVE_WORDS,
365            outputs: &OUT_DOCUMENTS_OR_BAG,
366        },
367        BuiltinSignatureDescriptor {
368            label: "newDocumentsOrBag = removeWords(___, 'IgnoreCase', tf)",
369            inputs: &IN_REMOVE_WORDS,
370            outputs: &OUT_DOCUMENTS_OR_BAG,
371        },
372    ],
373    output_mode: BuiltinOutputMode::Fixed,
374    completion_policy: BuiltinCompletionPolicy::Public,
375    errors: &REMOVE_WORDS_ERRORS,
376};
377
378pub const REMOVE_STOP_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
379    signatures: &[
380        BuiltinSignatureDescriptor {
381            label: "newDocuments = removeStopWords(documents)",
382            inputs: &IN_REMOVE_STOP_DOCUMENTS,
383            outputs: &OUT_DOCUMENTS_FILTERED,
384        },
385        BuiltinSignatureDescriptor {
386            label: "newDocuments = removeStopWords(documents, Name, Value, ...)",
387            inputs: &IN_REMOVE_STOP,
388            outputs: &OUT_DOCUMENTS_FILTERED,
389        },
390    ],
391    output_mode: BuiltinOutputMode::Fixed,
392    completion_policy: BuiltinCompletionPolicy::Public,
393    errors: &REMOVE_STOP_WORDS_ERRORS,
394};
395
396fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
397    Type::Unknown
398}
399
400pub(in crate::builtins::strings::text_analytics) fn text_analytics_error(
401    fn_name: &str,
402    message: impl Into<String>,
403) -> crate::RuntimeError {
404    let descriptor = match fn_name {
405        "removeStopWords" => ERROR_REMOVE_STOP_WORDS_INVALID_INPUT,
406        "removeWords" => ERROR_REMOVE_WORDS_INVALID_INPUT,
407        "removeLongWords" => ERROR_REMOVE_LONG_WORDS_INVALID_INPUT,
408        "tokenDetails" => ERROR_TOKEN_DETAILS_INVALID_INPUT,
409        "addTypeDetails" => ERROR_ADD_TYPE_DETAILS_INVALID_INPUT,
410        "addSentenceDetails" => ERROR_ADD_SENTENCE_DETAILS_INVALID_INPUT,
411        "addLemmaDetails" => ERROR_ADD_LEMMA_DETAILS_INVALID_INPUT,
412        "addPartOfSpeechDetails" => ERROR_ADD_PART_OF_SPEECH_DETAILS_INVALID_INPUT,
413        "addEntityDetails" => ERROR_ADD_ENTITY_DETAILS_INVALID_INPUT,
414        "addDependencyDetails" => ERROR_ADD_DEPENDENCY_DETAILS_INVALID_INPUT,
415        "vaderSentimentScores" => ERROR_VADER_SENTIMENT_SCORES_INVALID_INPUT,
416        _ => ERROR_INVALID_INPUT,
417    };
418    let builder = build_runtime_error(message).with_builtin(fn_name);
419    match descriptor.identifier {
420        Some(identifier) => builder.with_identifier(identifier).build(),
421        None => builder.build(),
422    }
423}
424
425fn ensure_tokenized_document_class_registered() {
426    TOKENIZED_DOCUMENT_CLASS_REGISTERED.with(|registered| {
427        if registered.get() {
428            return;
429        }
430        let mut properties = HashMap::new();
431        for name in [
432            "Documents",
433            "Vocabulary",
434            "NumDocuments",
435            "DocumentLengths",
436            "Shape",
437            "TokenizeMethod",
438            "Language",
439            "DetectPatterns",
440            "TopLevelDomains",
441            "TopLevelDomainsCustom",
442            "TypeDetails",
443            "SentenceNumbers",
444            "LemmaDetails",
445            "PartOfSpeechDetails",
446            "EntityDetails",
447            "HeadDetails",
448            "DependencyDetails",
449        ] {
450            properties.insert(name.to_string(), property_def(name));
451        }
452        runmat_builtins::register_class(ClassDef {
453            name: TOKENIZED_DOCUMENT_CLASS.to_string(),
454            parent: None,
455            properties,
456            methods: HashMap::new(),
457        });
458        registered.set(true);
459    });
460}
461
462fn ensure_bag_of_words_class_registered() {
463    BAG_OF_WORDS_CLASS_REGISTERED.with(|registered| {
464        if registered.get() {
465            return;
466        }
467        let mut properties = HashMap::new();
468        for name in ["Counts", "Vocabulary", "NumWords", "NumDocuments"] {
469            properties.insert(name.to_string(), property_def(name));
470        }
471        runmat_builtins::register_class(ClassDef {
472            name: BAG_OF_WORDS_CLASS.to_string(),
473            parent: None,
474            properties,
475            methods: HashMap::new(),
476        });
477        registered.set(true);
478    });
479}
480
481fn property_def(name: &str) -> PropertyDef {
482    PropertyDef {
483        name: name.to_string(),
484        is_static: false,
485        is_constant: false,
486        is_dependent: false,
487        get_access: Access::Public,
488        set_access: Access::Public,
489        default_value: None,
490    }
491}
492
493#[runtime_builtin(
494    name = "tokenizedDocument",
495    category = "strings/text_analytics",
496    summary = "Create tokenized document objects for Text Analytics workflows.",
497    keywords = "tokenizedDocument,text analytics,tokenize,document",
498    accel = "sink",
499    type_resolver(any_type),
500    descriptor(crate::builtins::strings::text_analytics::documents::TOKENIZED_DOCUMENT_DESCRIPTOR),
501    builtin_path = "crate::builtins::strings::text_analytics::documents"
502)]
503pub(in crate::builtins::strings::text_analytics) async fn tokenized_document_builtin(
504    args: Vec<Value>,
505) -> BuiltinResult<Value> {
506    let gathered = gather_args(args, "tokenizedDocument").await?;
507    let (input, options) = parse_tokenized_document_args(gathered)?;
508    let parsed = match input {
509        Some(value) => documents_from_value(value, &options)?,
510        None => ParsedDocuments {
511            documents: vec![Vec::new()],
512            shape: vec![1, 1],
513            type_details: None,
514        },
515    };
516    tokenized_document_value(parsed.documents, parsed.shape, options, parsed.type_details)
517}
518
519#[runtime_builtin(
520    name = "bagOfWords",
521    category = "strings/text_analytics",
522    summary = "Create bag-of-words model objects.",
523    keywords = "bagOfWords,text analytics,word counts,vocabulary",
524    accel = "sink",
525    type_resolver(any_type),
526    descriptor(crate::builtins::strings::text_analytics::documents::BAG_OF_WORDS_DESCRIPTOR),
527    builtin_path = "crate::builtins::strings::text_analytics::documents"
528)]
529async fn bag_of_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
530    let gathered = gather_args(args, "bagOfWords").await?;
531    match gathered.as_slice() {
532        [] => bag_from_documents(Vec::new()),
533        [value] => match value {
534            Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
535                bag_from_documents(documents_from_object(object, "bagOfWords")?)
536            }
537            Value::Object(object) => Err(text_analytics_error(
538                "bagOfWords",
539                format!(
540                    "bagOfWords: expected tokenizedDocument object, got {}",
541                    object.class_name
542                ),
543            )),
544            other => bag_from_documents(vec![words_from_word_vector(other, "bagOfWords")?]),
545        },
546        [words, counts] => bag_from_unique_words_and_counts(words, counts),
547        _ => Err(text_analytics_error(
548            "bagOfWords",
549            "bagOfWords: expected zero, one, or two inputs",
550        )),
551    }
552}
553
554#[runtime_builtin(
555    name = "removeShortWords",
556    category = "strings/text_analytics",
557    summary = "Remove short words from tokenized documents or bag-of-words models.",
558    keywords = "removeShortWords,text analytics,tokenizedDocument,bagOfWords",
559    accel = "sink",
560    type_resolver(any_type),
561    descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_SHORT_WORDS_DESCRIPTOR),
562    builtin_path = "crate::builtins::strings::text_analytics::documents"
563)]
564async fn remove_short_words_builtin(value: Value, len: Value) -> BuiltinResult<Value> {
565    let value = gather_if_needed_async(&value).await.map_err(|err| {
566        text_analytics_error("removeShortWords", format!("removeShortWords: {err}"))
567    })?;
568    let len = gather_if_needed_async(&len).await.map_err(|err| {
569        text_analytics_error("removeShortWords", format!("removeShortWords: {err}"))
570    })?;
571    let max_len = parse_positive_integer(&len, "removeShortWords")?;
572    match value {
573        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
574            let documents = documents_from_object(&object, "removeShortWords")?
575                .into_iter()
576                .map(|doc| {
577                    doc.into_iter()
578                        .filter(|token| token.chars().count() > max_len)
579                        .collect::<Vec<_>>()
580                })
581                .collect::<Vec<_>>();
582            let shape = shape_from_object(&object);
583            let options = options_from_document_object(&object);
584            tokenized_document_value(documents, shape, options, None)
585        }
586        Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
587            remove_short_words_from_bag(object, max_len)
588        }
589        Value::Object(object) => Err(text_analytics_error(
590            "removeShortWords",
591            format!(
592                "removeShortWords: expected tokenizedDocument or bagOfWords object, got {}",
593                object.class_name
594            ),
595        )),
596        other => Err(text_analytics_error(
597            "removeShortWords",
598            format!(
599                "removeShortWords: expected tokenizedDocument or bagOfWords object, got {other:?}"
600            ),
601        )),
602    }
603}
604
605#[runtime_builtin(
606    name = "removeLongWords",
607    category = "strings/text_analytics",
608    summary = "Remove long words from tokenized documents or bag-of-words models.",
609    keywords = "removeLongWords,text analytics,tokenizedDocument,bagOfWords",
610    accel = "sink",
611    type_resolver(any_type),
612    descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_LONG_WORDS_DESCRIPTOR),
613    builtin_path = "crate::builtins::strings::text_analytics::documents"
614)]
615async fn remove_long_words_builtin(value: Value, len: Value) -> BuiltinResult<Value> {
616    let value = gather_if_needed_async(&value).await.map_err(|err| {
617        text_analytics_error("removeLongWords", format!("removeLongWords: {err}"))
618    })?;
619    let len = gather_if_needed_async(&len).await.map_err(|err| {
620        text_analytics_error("removeLongWords", format!("removeLongWords: {err}"))
621    })?;
622    let min_len = parse_positive_integer(&len, "removeLongWords")?;
623    match value {
624        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
625            transform_tokenized_document(&object, "removeLongWords", |token, _| {
626                Ok((token.chars().count() < min_len).then(|| token.to_string()))
627            })
628        }
629        Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
630            filter_bag_columns_by_predicate(object, "removeLongWords", |word| {
631                word.chars().count() < min_len
632            })
633        }
634        Value::Object(object) => Err(text_analytics_error(
635            "removeLongWords",
636            format!(
637                "removeLongWords: expected tokenizedDocument or bagOfWords object, got {}",
638                object.class_name
639            ),
640        )),
641        other => Err(text_analytics_error(
642            "removeLongWords",
643            format!(
644                "removeLongWords: expected tokenizedDocument or bagOfWords object, got {other:?}"
645            ),
646        )),
647    }
648}
649
650#[runtime_builtin(
651    name = "removeWords",
652    category = "strings/text_analytics",
653    summary = "Remove selected words from tokenized documents or bag-of-words models.",
654    keywords = "removeWords,text analytics,tokenizedDocument,bagOfWords,filter",
655    accel = "sink",
656    type_resolver(any_type),
657    descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_WORDS_DESCRIPTOR),
658    builtin_path = "crate::builtins::strings::text_analytics::documents"
659)]
660async fn remove_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
661    let gathered = gather_args(args, "removeWords").await?;
662    let (value, selector, options) = parse_remove_words_args(gathered)?;
663    match value {
664        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
665            let vocabulary = documents_vocabulary(&object, "removeWords")?;
666            let words = selector.words(&vocabulary, "removeWords")?;
667            let remove_set = word_set(words, options.ignore_case);
668            transform_tokenized_document(&object, "removeWords", |token, _| {
669                let key = comparable_word(token, options.ignore_case);
670                Ok((!remove_set.contains(&key)).then(|| token.to_string()))
671            })
672        }
673        Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
674            let vocabulary = vocabulary_from_bag(&object, "removeWords")?;
675            let words = selector.words(&vocabulary, "removeWords")?;
676            let remove_set = word_set(words, options.ignore_case);
677            filter_bag_columns(object, "removeWords", |word| {
678                let key = comparable_word(word, options.ignore_case);
679                !remove_set.contains(&key)
680            })
681        }
682        Value::Object(object) => Err(text_analytics_error(
683            "removeWords",
684            format!(
685                "removeWords: expected tokenizedDocument or bagOfWords object, got {}",
686                object.class_name
687            ),
688        )),
689        other => Err(text_analytics_error(
690            "removeWords",
691            format!("removeWords: expected tokenizedDocument or bagOfWords object, got {other:?}"),
692        )),
693    }
694}
695
696#[runtime_builtin(
697    name = "removeStopWords",
698    category = "strings/text_analytics",
699    summary = "Remove stop words from tokenized documents.",
700    keywords = "removeStopWords,stop words,text analytics,tokenizedDocument",
701    accel = "sink",
702    type_resolver(any_type),
703    descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_STOP_WORDS_DESCRIPTOR),
704    builtin_path = "crate::builtins::strings::text_analytics::documents"
705)]
706async fn remove_stop_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
707    let gathered = gather_args(args, "removeStopWords").await?;
708    let (value, options) = parse_remove_stop_words_args(gathered)?;
709    let Value::Object(object) = value else {
710        return Err(text_analytics_error(
711            "removeStopWords",
712            format!("removeStopWords: expected tokenizedDocument object, got {value:?}"),
713        ));
714    };
715    if !object.is_class(TOKENIZED_DOCUMENT_CLASS) {
716        return Err(text_analytics_error(
717            "removeStopWords",
718            format!(
719                "removeStopWords: expected tokenizedDocument object, got {}",
720                object.class_name
721            ),
722        ));
723    }
724    let language = stop_words_language_from_document_object(&object, "removeStopWords")?;
725    let words = stop_words_for_language(language);
726    let stop_words = words
727        .iter()
728        .map(|word| {
729            if options.ignore_case {
730                word.to_lowercase()
731            } else {
732                (*word).to_string()
733            }
734        })
735        .collect::<HashSet<_>>();
736    transform_tokenized_document(&object, "removeStopWords", |token, token_type| {
737        if !matches!(
738            token_type,
739            DocumentTokenType::Letters | DocumentTokenType::Other
740        ) {
741            return Ok(Some(token.to_string()));
742        }
743        let key = if options.ignore_case {
744            token.to_lowercase()
745        } else {
746            token.to_string()
747        };
748        Ok((!stop_words.contains(&key)).then(|| token.to_string()))
749    })
750}
751
752async fn gather_args(args: Vec<Value>, fn_name: &str) -> BuiltinResult<Vec<Value>> {
753    let mut out = Vec::with_capacity(args.len());
754    for arg in args {
755        out.push(gather_if_needed_async(&arg).await.map_err(|err| {
756            text_analytics_error(fn_name, format!("{fn_name}: failed to gather input: {err}"))
757        })?);
758    }
759    Ok(out)
760}
761
762#[derive(Clone, Debug)]
763pub(in crate::builtins::strings::text_analytics) struct DocumentOptions {
764    pub(in crate::builtins::strings::text_analytics) tokenize_method: TokenizeMethod,
765    pub(in crate::builtins::strings::text_analytics) language: String,
766    pub(in crate::builtins::strings::text_analytics) detect_patterns: DetectPatterns,
767    pub(in crate::builtins::strings::text_analytics) top_level_domains: Vec<String>,
768    pub(in crate::builtins::strings::text_analytics) top_level_domains_custom: bool,
769    custom_tokens: Vec<CustomTokenRule>,
770    regular_expressions: Vec<RegexTokenRule>,
771}
772
773impl Default for DocumentOptions {
774    fn default() -> Self {
775        Self {
776            tokenize_method: TokenizeMethod::Unicode,
777            language: "en".to_string(),
778            detect_patterns: DetectPatterns::All,
779            top_level_domains: default_top_level_domains(),
780            top_level_domains_custom: false,
781            custom_tokens: Vec::new(),
782            regular_expressions: Vec::new(),
783        }
784    }
785}
786
787impl DocumentOptions {
788    fn requires_type_details(&self) -> bool {
789        !self.custom_tokens.is_empty() || !self.regular_expressions.is_empty()
790    }
791}
792
793#[derive(Clone, Debug)]
794struct CustomTokenRule {
795    token: String,
796    token_type: String,
797}
798
799#[derive(Clone, Debug)]
800struct RegexTokenRule {
801    regex: Regex,
802    token_type: String,
803}
804
805#[derive(Clone, Copy, Debug, PartialEq, Eq)]
806pub(in crate::builtins::strings::text_analytics) enum TokenizeMethod {
807    Unicode,
808    None,
809}
810
811#[derive(Clone, Copy, Debug, PartialEq, Eq)]
812pub(in crate::builtins::strings::text_analytics) enum DetectPatterns {
813    All,
814    None,
815    Selected(ComplexPatternSet),
816}
817
818#[derive(Clone, Copy, Debug, PartialEq, Eq)]
819pub(in crate::builtins::strings::text_analytics) struct ComplexPatternSet {
820    email_address: bool,
821    web_address: bool,
822    hashtag: bool,
823    at_mention: bool,
824    emoticon: bool,
825}
826
827impl ComplexPatternSet {
828    fn empty() -> Self {
829        Self {
830            email_address: false,
831            web_address: false,
832            hashtag: false,
833            at_mention: false,
834            emoticon: false,
835        }
836    }
837}
838
839impl DetectPatterns {
840    fn detects_email_address(self) -> bool {
841        matches!(
842            self,
843            Self::All
844                | Self::Selected(ComplexPatternSet {
845                    email_address: true,
846                    ..
847                })
848        )
849    }
850
851    fn detects_web_address(self) -> bool {
852        matches!(
853            self,
854            Self::All
855                | Self::Selected(ComplexPatternSet {
856                    web_address: true,
857                    ..
858                })
859        )
860    }
861
862    fn detects_hashtag(self) -> bool {
863        matches!(
864            self,
865            Self::All | Self::Selected(ComplexPatternSet { hashtag: true, .. })
866        )
867    }
868
869    fn detects_at_mention(self) -> bool {
870        matches!(
871            self,
872            Self::All
873                | Self::Selected(ComplexPatternSet {
874                    at_mention: true,
875                    ..
876                })
877        )
878    }
879
880    fn detects_emoticon(self) -> bool {
881        matches!(
882            self,
883            Self::All | Self::Selected(ComplexPatternSet { emoticon: true, .. })
884        )
885    }
886}
887
888fn parse_tokenized_document_args(
889    args: Vec<Value>,
890) -> BuiltinResult<(Option<Value>, DocumentOptions)> {
891    if args.is_empty() {
892        return Ok((None, DocumentOptions::default()));
893    }
894    if !(args.len() - 1).is_multiple_of(2) {
895        return Err(text_analytics_error(
896            "tokenizedDocument",
897            "tokenizedDocument: name-value options must appear in pairs",
898        ));
899    }
900    let input = args[0].clone();
901    let mut options = DocumentOptions::default();
902    let mut idx = 1;
903    while idx < args.len() {
904        let name = scalar_text(&args[idx], "tokenizedDocument")
905            .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?
906            .to_ascii_lowercase();
907        match name.as_str() {
908            "tokenizemethod" => {
909                let value = scalar_text(&args[idx + 1], "tokenizedDocument")
910                    .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
911                options.tokenize_method = parse_tokenize_method(&value)?;
912            }
913            "language" => {
914                let value = scalar_text(&args[idx + 1], "tokenizedDocument")
915                    .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
916                options.language = parse_language(&value)?;
917            }
918            "detectpatterns" => {
919                options.detect_patterns =
920                    parse_detect_patterns(&args[idx + 1], "tokenizedDocument")?;
921            }
922            "topleveldomains" => {
923                options.top_level_domains =
924                    parse_top_level_domains(&args[idx + 1], "tokenizedDocument")?;
925                options.top_level_domains_custom = true;
926            }
927            "customtokens" => {
928                options.custom_tokens = parse_custom_tokens(&args[idx + 1])?;
929            }
930            "regularexpressions" => {
931                options.regular_expressions = parse_regular_expressions(&args[idx + 1])?;
932            }
933            _ => {
934                return Err(text_analytics_error(
935                    "tokenizedDocument",
936                    format!("tokenizedDocument: unsupported option '{name}'"),
937                ));
938            }
939        }
940        idx += 2;
941    }
942    Ok((Some(input), options))
943}
944
945fn parse_tokenize_method(value: &str) -> BuiltinResult<TokenizeMethod> {
946    match value.trim().to_ascii_lowercase().as_str() {
947        "unicode" => Ok(TokenizeMethod::Unicode),
948        "none" => Ok(TokenizeMethod::None),
949        "mecab" => Err(text_analytics_error(
950            "tokenizedDocument",
951            "tokenizedDocument: TokenizeMethod 'mecab' requires Japanese/Korean tokenizer support and remains tracked",
952        )),
953        other => Err(text_analytics_error(
954            "tokenizedDocument",
955            format!("tokenizedDocument: unsupported TokenizeMethod '{other}'"),
956        )),
957    }
958}
959
960fn parse_language(value: &str) -> BuiltinResult<String> {
961    match value.trim().to_ascii_lowercase().as_str() {
962        "en" | "de" => Ok(value.trim().to_ascii_lowercase()),
963        "ja" | "ko" => Err(text_analytics_error(
964            "tokenizedDocument",
965            "tokenizedDocument: Japanese/Korean tokenization requires MeCab-compatible support and remains tracked",
966        )),
967        other => Err(text_analytics_error(
968            "tokenizedDocument",
969            format!("tokenizedDocument: Language must be 'en' or 'de' in this slice, got '{other}'"),
970        )),
971    }
972}
973
974fn parse_detect_patterns(value: &Value, fn_name: &str) -> BuiltinResult<DetectPatterns> {
975    let patterns = words_from_word_vector(value, fn_name)?
976        .into_iter()
977        .map(|pattern| pattern.trim().to_ascii_lowercase())
978        .filter(|pattern| !pattern.is_empty())
979        .collect::<Vec<_>>();
980    if patterns.is_empty() {
981        return Err(text_analytics_error(
982            fn_name,
983            format!("{fn_name}: DetectPatterns must contain at least one pattern"),
984        ));
985    }
986    if patterns.len() == 1 {
987        match patterns[0].as_str() {
988            "all" => return Ok(DetectPatterns::All),
989            "none" => return Ok(DetectPatterns::None),
990            _ => {}
991        }
992    }
993
994    let mut selected = ComplexPatternSet::empty();
995    for pattern in patterns {
996        match pattern.as_str() {
997            "email-address" => selected.email_address = true,
998            "web-address" => selected.web_address = true,
999            "hashtag" => selected.hashtag = true,
1000            "at-mention" => selected.at_mention = true,
1001            "emoticon" => selected.emoticon = true,
1002            "all" | "none" => {
1003                return Err(text_analytics_error(
1004                    fn_name,
1005                    format!("{fn_name}: DetectPatterns '{pattern}' must be specified alone"),
1006                ))
1007            }
1008            other => {
1009                return Err(text_analytics_error(
1010                    fn_name,
1011                    format!("{fn_name}: unsupported DetectPatterns value '{other}'"),
1012                ))
1013            }
1014        }
1015    }
1016    Ok(DetectPatterns::Selected(selected))
1017}
1018
1019pub(in crate::builtins::strings::text_analytics) fn parse_top_level_domains(
1020    value: &Value,
1021    fn_name: &str,
1022) -> BuiltinResult<Vec<String>> {
1023    let domains = words_from_word_vector_preserving_missing(value, fn_name)?;
1024    let mut out = Vec::new();
1025    let mut seen = HashSet::new();
1026    for raw in domains {
1027        if is_missing_string(&raw) {
1028            continue;
1029        }
1030        let domain = raw
1031            .trim()
1032            .trim_start_matches('.')
1033            .trim_end_matches('.')
1034            .to_ascii_lowercase();
1035        if domain.is_empty()
1036            || domain.contains('.')
1037            || !domain
1038                .chars()
1039                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1040            || domain.starts_with('-')
1041            || domain.ends_with('-')
1042        {
1043            return Err(text_analytics_error(
1044                fn_name,
1045                format!("{fn_name}: TopLevelDomains contains invalid domain '{raw}'"),
1046            ));
1047        }
1048        if seen.insert(domain.clone()) {
1049            out.push(domain);
1050        }
1051    }
1052    if out.is_empty() {
1053        return Err(text_analytics_error(
1054            fn_name,
1055            format!("{fn_name}: TopLevelDomains must contain at least one domain"),
1056        ));
1057    }
1058    Ok(out)
1059}
1060
1061fn parse_custom_tokens(value: &Value) -> BuiltinResult<Vec<CustomTokenRule>> {
1062    if let Value::Object(object) = value {
1063        let variables = table_variables(object).map_err(|err| {
1064            text_analytics_error(
1065                "tokenizedDocument",
1066                format!("tokenizedDocument: invalid CustomTokens table: {err}"),
1067            )
1068        })?;
1069        if variables.fields.contains_key("Token") || variables.fields.contains_key("Type") {
1070            let tokens = text_column(&variables, "Token", "CustomTokens")?;
1071            let types = optional_text_column(&variables, "Type", "CustomTokens", tokens.len())?;
1072            return custom_token_rules_from_columns(tokens, types);
1073        }
1074    }
1075    let tokens = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1076    custom_token_rules_from_columns(tokens, None)
1077}
1078
1079fn parse_regular_expressions(value: &Value) -> BuiltinResult<Vec<RegexTokenRule>> {
1080    if let Value::Object(object) = value {
1081        let variables = table_variables(object).map_err(|err| {
1082            text_analytics_error(
1083                "tokenizedDocument",
1084                format!("tokenizedDocument: invalid RegularExpressions table: {err}"),
1085            )
1086        })?;
1087        if variables.fields.contains_key("Pattern") || variables.fields.contains_key("Type") {
1088            let patterns = text_column(&variables, "Pattern", "RegularExpressions")?;
1089            let types =
1090                optional_text_column(&variables, "Type", "RegularExpressions", patterns.len())?;
1091            return regex_token_rules_from_columns(patterns, types);
1092        }
1093    }
1094    let patterns = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1095    regex_token_rules_from_columns(patterns, None)
1096}
1097
1098fn text_column(
1099    variables: &runmat_builtins::StructValue,
1100    name: &str,
1101    option_name: &str,
1102) -> BuiltinResult<Vec<String>> {
1103    let Some(value) = variables.fields.get(name) else {
1104        return Err(text_analytics_error(
1105            "tokenizedDocument",
1106            format!("tokenizedDocument: {option_name} table must contain a {name} variable"),
1107        ));
1108    };
1109    words_from_word_vector_preserving_missing(value, "tokenizedDocument")
1110}
1111
1112fn optional_text_column(
1113    variables: &runmat_builtins::StructValue,
1114    name: &str,
1115    option_name: &str,
1116    expected_len: usize,
1117) -> BuiltinResult<Option<Vec<String>>> {
1118    let Some(value) = variables.fields.get(name) else {
1119        return Ok(None);
1120    };
1121    let values = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1122    if values.len() != expected_len {
1123        return Err(text_analytics_error(
1124            "tokenizedDocument",
1125            format!(
1126                "tokenizedDocument: {option_name} table variable {name} has {} rows but expected {expected_len}",
1127                values.len()
1128            ),
1129        ));
1130    }
1131    Ok(Some(values))
1132}
1133
1134fn custom_token_rules_from_columns(
1135    tokens: Vec<String>,
1136    types: Option<Vec<String>>,
1137) -> BuiltinResult<Vec<CustomTokenRule>> {
1138    let mut rules = Vec::new();
1139    let mut seen = HashSet::new();
1140    for (idx, token) in tokens.into_iter().enumerate() {
1141        if is_missing_string(&token) {
1142            continue;
1143        }
1144        if token.is_empty() {
1145            return Err(text_analytics_error(
1146                "tokenizedDocument",
1147                "tokenizedDocument: CustomTokens cannot contain empty tokens",
1148            ));
1149        }
1150        let token_type = types
1151            .as_ref()
1152            .and_then(|values| values.get(idx))
1153            .filter(|value| !is_missing_string(value) && !value.is_empty())
1154            .cloned()
1155            .unwrap_or_else(|| "custom".to_string());
1156        if seen.insert(token.clone()) {
1157            rules.push(CustomTokenRule { token, token_type });
1158        }
1159    }
1160    Ok(rules)
1161}
1162
1163fn regex_token_rules_from_columns(
1164    patterns: Vec<String>,
1165    types: Option<Vec<String>>,
1166) -> BuiltinResult<Vec<RegexTokenRule>> {
1167    let mut rules = Vec::new();
1168    for (idx, pattern) in patterns.into_iter().enumerate() {
1169        if is_missing_string(&pattern) {
1170            continue;
1171        }
1172        if pattern.is_empty() {
1173            return Err(text_analytics_error(
1174                "tokenizedDocument",
1175                "tokenizedDocument: RegularExpressions cannot contain empty patterns",
1176            ));
1177        }
1178        let regex = Regex::new(&pattern).map_err(|err| {
1179            text_analytics_error(
1180                "tokenizedDocument",
1181                format!("tokenizedDocument: invalid RegularExpressions pattern '{pattern}': {err}"),
1182            )
1183        })?;
1184        if regex
1185            .find("")
1186            .is_some_and(|mat| mat.start() == 0 && mat.end() == 0)
1187        {
1188            return Err(text_analytics_error(
1189                "tokenizedDocument",
1190                format!(
1191                    "tokenizedDocument: RegularExpressions pattern '{pattern}' can match empty text"
1192                ),
1193            ));
1194        }
1195        let token_type = types
1196            .as_ref()
1197            .and_then(|values| values.get(idx))
1198            .filter(|value| !is_missing_string(value) && !value.is_empty())
1199            .cloned()
1200            .unwrap_or_else(|| "custom".to_string());
1201        rules.push(RegexTokenRule { regex, token_type });
1202    }
1203    Ok(rules)
1204}
1205
1206#[derive(Clone, Copy, Debug)]
1207struct RemoveStopWordsOptions {
1208    ignore_case: bool,
1209}
1210
1211impl Default for RemoveStopWordsOptions {
1212    fn default() -> Self {
1213        Self { ignore_case: true }
1214    }
1215}
1216
1217#[derive(Clone, Copy, Debug, Default)]
1218struct RemoveWordsOptions {
1219    ignore_case: bool,
1220}
1221
1222#[derive(Clone, Debug)]
1223enum RemoveWordsSelector {
1224    Words(Vec<String>),
1225    Indices(Vec<usize>),
1226    LogicalMask { indices: Vec<usize>, len: usize },
1227}
1228
1229impl RemoveWordsSelector {
1230    fn words(self, vocabulary: &[String], fn_name: &str) -> BuiltinResult<Vec<String>> {
1231        match self {
1232            Self::Words(words) => Ok(words),
1233            Self::LogicalMask { indices, len } => {
1234                if len != vocabulary.len() {
1235                    return Err(text_analytics_error(
1236                        fn_name,
1237                        format!(
1238                            "{fn_name}: logical index length {len} must match vocabulary length {}",
1239                            vocabulary.len()
1240                        ),
1241                    ));
1242                }
1243                indices
1244                    .into_iter()
1245                    .map(|idx| {
1246                        vocabulary.get(idx).cloned().ok_or_else(|| {
1247                            text_analytics_error(
1248                                fn_name,
1249                                format!(
1250                                    "{fn_name}: vocabulary index {} exceeds vocabulary length {}",
1251                                    idx + 1,
1252                                    vocabulary.len()
1253                                ),
1254                            )
1255                        })
1256                    })
1257                    .collect()
1258            }
1259            Self::Indices(indices) => indices
1260                .into_iter()
1261                .map(|idx| {
1262                    vocabulary.get(idx).cloned().ok_or_else(|| {
1263                        text_analytics_error(
1264                            fn_name,
1265                            format!(
1266                                "{fn_name}: vocabulary index {} exceeds vocabulary length {}",
1267                                idx + 1,
1268                                vocabulary.len()
1269                            ),
1270                        )
1271                    })
1272                })
1273                .collect(),
1274        }
1275    }
1276}
1277
1278fn parse_remove_stop_words_args(
1279    args: Vec<Value>,
1280) -> BuiltinResult<(Value, RemoveStopWordsOptions)> {
1281    if args.is_empty() {
1282        return Err(text_analytics_error(
1283            "removeStopWords",
1284            "removeStopWords: expected tokenizedDocument input",
1285        ));
1286    }
1287    if !(args.len() - 1).is_multiple_of(2) {
1288        return Err(text_analytics_error(
1289            "removeStopWords",
1290            "removeStopWords: name-value options must appear in pairs",
1291        ));
1292    }
1293    let mut options = RemoveStopWordsOptions::default();
1294    let mut idx = 1;
1295    while idx < args.len() {
1296        let name = scalar_text(&args[idx], "removeStopWords")
1297            .map_err(|err| text_analytics_error("removeStopWords", err.to_string()))?
1298            .to_ascii_lowercase();
1299        match name.as_str() {
1300            "ignorecase" => {
1301                options.ignore_case = parse_bool_scalar(&args[idx + 1], "removeStopWords")?;
1302            }
1303            other => {
1304                return Err(text_analytics_error(
1305                    "removeStopWords",
1306                    format!("removeStopWords: unsupported option '{other}'"),
1307                ));
1308            }
1309        }
1310        idx += 2;
1311    }
1312    Ok((args[0].clone(), options))
1313}
1314
1315fn parse_remove_words_args(
1316    args: Vec<Value>,
1317) -> BuiltinResult<(Value, RemoveWordsSelector, RemoveWordsOptions)> {
1318    if args.len() < 2 {
1319        return Err(text_analytics_error(
1320            "removeWords",
1321            "removeWords: expected input object and words or indices",
1322        ));
1323    }
1324    if !(args.len() - 2).is_multiple_of(2) {
1325        return Err(text_analytics_error(
1326            "removeWords",
1327            "removeWords: name-value options must appear in pairs",
1328        ));
1329    }
1330    let mut options = RemoveWordsOptions::default();
1331    let mut idx = 2;
1332    while idx < args.len() {
1333        let name = scalar_text(&args[idx], "removeWords")
1334            .map_err(|err| text_analytics_error("removeWords", err.to_string()))?
1335            .to_ascii_lowercase();
1336        match name.as_str() {
1337            "ignorecase" => {
1338                options.ignore_case = parse_bool_scalar(&args[idx + 1], "removeWords")?;
1339            }
1340            other => {
1341                return Err(text_analytics_error(
1342                    "removeWords",
1343                    format!("removeWords: unsupported option '{other}'"),
1344                ));
1345            }
1346        }
1347        idx += 2;
1348    }
1349    let selector = parse_remove_words_selector(&args[1])?;
1350    Ok((args[0].clone(), selector, options))
1351}
1352
1353fn parse_remove_words_selector(value: &Value) -> BuiltinResult<RemoveWordsSelector> {
1354    match value {
1355        Value::Bool(value) => Ok(RemoveWordsSelector::LogicalMask {
1356            indices: (*value).then_some(0).into_iter().collect(),
1357            len: 1,
1358        }),
1359        Value::LogicalArray(array) => {
1360            let indices = logical_indices(array);
1361            Ok(RemoveWordsSelector::LogicalMask {
1362                indices,
1363                len: array.data.len(),
1364            })
1365        }
1366        Value::Num(_) | Value::Tensor(_) => {
1367            parse_remove_words_indices(value).map(RemoveWordsSelector::Indices)
1368        }
1369        _ => words_from_word_vector(value, "removeWords").map(RemoveWordsSelector::Words),
1370    }
1371}
1372
1373fn parse_remove_words_indices(value: &Value) -> BuiltinResult<Vec<usize>> {
1374    let raw = match value {
1375        Value::Num(value) => vec![*value],
1376        Value::Tensor(tensor) => tensor.data.clone(),
1377        other => {
1378            return Err(text_analytics_error(
1379                "removeWords",
1380                format!("removeWords: expected numeric or logical indices, got {other:?}"),
1381            ))
1382        }
1383    };
1384    if raw.is_empty() {
1385        return Ok(Vec::new());
1386    }
1387    let mut seen = HashSet::new();
1388    let mut indices = Vec::new();
1389    for value in raw {
1390        if !value.is_finite() || value <= 0.0 || value.fract() != 0.0 {
1391            return Err(text_analytics_error(
1392                "removeWords",
1393                format!("removeWords: vocabulary indices must be positive integers, got {value}"),
1394            ));
1395        }
1396        let idx = value as usize - 1;
1397        if seen.insert(idx) {
1398            indices.push(idx);
1399        }
1400    }
1401    Ok(indices)
1402}
1403
1404fn logical_indices(array: &LogicalArray) -> Vec<usize> {
1405    array
1406        .data
1407        .iter()
1408        .enumerate()
1409        .filter_map(|(idx, flag)| (*flag != 0).then_some(idx))
1410        .collect()
1411}
1412
1413fn word_set(words: Vec<String>, ignore_case: bool) -> HashSet<String> {
1414    words
1415        .into_iter()
1416        .map(|word| comparable_word(&word, ignore_case))
1417        .collect()
1418}
1419
1420fn comparable_word(word: &str, ignore_case: bool) -> String {
1421    if ignore_case {
1422        word.to_lowercase()
1423    } else {
1424        word.to_string()
1425    }
1426}
1427
1428struct ParsedDocuments {
1429    documents: Vec<Vec<String>>,
1430    shape: Vec<usize>,
1431    type_details: Option<Vec<Vec<String>>>,
1432}
1433
1434struct TokenizedText {
1435    tokens: Vec<String>,
1436    types: Option<Vec<String>>,
1437}
1438
1439fn documents_from_value(value: Value, options: &DocumentOptions) -> BuiltinResult<ParsedDocuments> {
1440    match options.tokenize_method {
1441        TokenizeMethod::Unicode => text_documents(value, options),
1442        TokenizeMethod::None => pretokenized_documents(value),
1443    }
1444}
1445
1446fn text_documents(value: Value, options: &DocumentOptions) -> BuiltinResult<ParsedDocuments> {
1447    match value {
1448        Value::String(text) => {
1449            let tokenized = tokenize_text(&text, options);
1450            Ok(ParsedDocuments {
1451                type_details: tokenized.types.clone().map(|types| vec![types]),
1452                documents: vec![tokenized.tokens],
1453                shape: vec![1, 1],
1454            })
1455        }
1456        Value::StringArray(array) => {
1457            let shape = array.shape.clone();
1458            let mut docs = Vec::with_capacity(array.data.len());
1459            let mut type_details = options.requires_type_details().then(Vec::new);
1460            for text in array.data {
1461                let tokenized = if is_missing_string(&text) {
1462                    TokenizedText {
1463                        tokens: Vec::new(),
1464                        types: type_details.as_ref().map(|_| Vec::new()),
1465                    }
1466                } else {
1467                    tokenize_text(&text, options)
1468                };
1469                if let Some(details) = &mut type_details {
1470                    details.push(tokenized.types.unwrap_or_default());
1471                }
1472                docs.push(tokenized.tokens);
1473            }
1474            Ok(ParsedDocuments {
1475                documents: docs,
1476                shape,
1477                type_details,
1478            })
1479        }
1480        Value::CharArray(array) if array.rows <= 1 => {
1481            let text = if array.rows == 0 {
1482                String::new()
1483            } else {
1484                char_row_to_string_slice(&array.data, array.cols, 0)
1485            };
1486            let tokenized = tokenize_text(&text, options);
1487            Ok(ParsedDocuments {
1488                type_details: tokenized.types.clone().map(|types| vec![types]),
1489                documents: vec![tokenized.tokens],
1490                shape: vec![1, 1],
1491            })
1492        }
1493        Value::CharArray(array) => {
1494            let mut docs = Vec::with_capacity(array.rows);
1495            let mut type_details = options.requires_type_details().then(Vec::new);
1496            for row in 0..array.rows {
1497                let tokenized = tokenize_text(
1498                    &char_row_to_string_slice(&array.data, array.cols, row),
1499                    options,
1500                );
1501                if let Some(details) = &mut type_details {
1502                    details.push(tokenized.types.unwrap_or_default());
1503                }
1504                docs.push(tokenized.tokens);
1505            }
1506            Ok(ParsedDocuments {
1507                documents: docs,
1508                shape: vec![array.rows, 1],
1509                type_details,
1510            })
1511        }
1512        Value::Cell(cell) => {
1513            let shape = cell.shape.clone();
1514            let mut docs = Vec::with_capacity(cell.data.len());
1515            let mut type_details = options.requires_type_details().then(Vec::new);
1516            for item in cell.data {
1517                let text = scalar_text(&item, "tokenizedDocument")
1518                    .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
1519                let tokenized = tokenize_text(&text, options);
1520                if let Some(details) = &mut type_details {
1521                    details.push(tokenized.types.unwrap_or_default());
1522                }
1523                docs.push(tokenized.tokens);
1524            }
1525            Ok(ParsedDocuments {
1526                documents: docs,
1527                shape,
1528                type_details,
1529            })
1530        }
1531        other => Err(text_analytics_error(
1532            "tokenizedDocument",
1533            format!("tokenizedDocument: expected text input, got {other:?}"),
1534        )),
1535    }
1536}
1537
1538fn pretokenized_documents(value: Value) -> BuiltinResult<ParsedDocuments> {
1539    match value {
1540        Value::String(text) => Ok(ParsedDocuments {
1541            documents: vec![vec![text]],
1542            shape: vec![1, 1],
1543            type_details: None,
1544        }),
1545        Value::StringArray(array) => Ok(ParsedDocuments {
1546            documents: vec![array
1547                .data
1548                .into_iter()
1549                .filter(|text| !is_missing_string(text))
1550                .collect()],
1551            shape: vec![1, 1],
1552            type_details: None,
1553        }),
1554        Value::CharArray(array) if array.rows <= 1 => {
1555            let text = if array.rows == 0 {
1556                String::new()
1557            } else {
1558                char_row_to_string_slice(&array.data, array.cols, 0)
1559            };
1560            Ok(ParsedDocuments {
1561                documents: vec![vec![text]],
1562                shape: vec![1, 1],
1563                type_details: None,
1564            })
1565        }
1566        Value::Cell(cell) => {
1567            let shape = cell.shape.clone();
1568            if cell.data.len() == 1 {
1569                if let Value::StringArray(array) = &cell.data[0] {
1570                    return Ok(ParsedDocuments {
1571                        documents: vec![array
1572                            .data
1573                            .iter()
1574                            .filter(|text| !is_missing_string(text))
1575                            .cloned()
1576                            .collect()],
1577                        shape: vec![1, 1],
1578                        type_details: None,
1579                    });
1580                }
1581            }
1582            let mut all_string_arrays = true;
1583            let mut docs = Vec::with_capacity(cell.data.len());
1584            for item in cell.data {
1585                match item {
1586                    Value::StringArray(array) => docs.push(
1587                        array
1588                            .data
1589                            .into_iter()
1590                            .filter(|text| !is_missing_string(text))
1591                            .collect(),
1592                    ),
1593                    other => {
1594                        all_string_arrays = false;
1595                        docs.push(vec![scalar_text(&other, "tokenizedDocument").map_err(
1596                            |err| text_analytics_error("tokenizedDocument", err.to_string()),
1597                        )?]);
1598                    }
1599                }
1600            }
1601            if all_string_arrays {
1602                Ok(ParsedDocuments {
1603                    documents: docs,
1604                    shape,
1605                    type_details: None,
1606                })
1607            } else {
1608                Ok(ParsedDocuments {
1609                    documents: vec![docs.into_iter().flatten().collect()],
1610                    shape: vec![1, 1],
1611                    type_details: None,
1612                })
1613            }
1614        }
1615        other => Err(text_analytics_error(
1616            "tokenizedDocument",
1617            format!("tokenizedDocument: expected pre-tokenized word vector, got {other:?}"),
1618        )),
1619    }
1620}
1621
1622fn tokenize_text(text: &str, options: &DocumentOptions) -> TokenizedText {
1623    let mut tokens = Vec::new();
1624    let mut types = options.requires_type_details().then(Vec::new);
1625    let mut pos = 0;
1626    while pos < text.len() {
1627        let rest = &text[pos..];
1628        if let Some(ch) = rest.chars().next() {
1629            if ch.is_whitespace() {
1630                pos += ch.len_utf8();
1631                continue;
1632            }
1633        }
1634        if let Some((token, token_type, end)) = custom_or_regex_token_at(text, pos, options) {
1635            if let Some(types) = &mut types {
1636                types.push(token_type);
1637            }
1638            tokens.push(token);
1639            pos = end;
1640            continue;
1641        }
1642        if let Some((token, end)) = complex_token_at(text, pos, options) {
1643            if let Some(types) = &mut types {
1644                types.push(
1645                    document_token_type_with_options(&token, options)
1646                        .as_str()
1647                        .to_string(),
1648                );
1649            }
1650            tokens.push(token);
1651            pos = end;
1652            continue;
1653        }
1654        let Some(ch) = rest.chars().next() else {
1655            break;
1656        };
1657        if ch.is_alphanumeric() || ch == '_' {
1658            let start = pos;
1659            pos += ch.len_utf8();
1660            while pos < text.len() {
1661                let next = text[pos..].chars().next().unwrap();
1662                if next.is_alphanumeric() || next == '_' || next == '\'' {
1663                    pos += next.len_utf8();
1664                } else {
1665                    break;
1666                }
1667            }
1668            let token = text[start..pos].to_string();
1669            if let Some(types) = &mut types {
1670                types.push(
1671                    document_token_type_with_options(&token, options)
1672                        .as_str()
1673                        .to_string(),
1674                );
1675            }
1676            tokens.push(token);
1677            continue;
1678        }
1679        let token = ch.to_string();
1680        if let Some(types) = &mut types {
1681            types.push(
1682                document_token_type_with_options(&token, options)
1683                    .as_str()
1684                    .to_string(),
1685            );
1686        }
1687        tokens.push(token);
1688        pos += ch.len_utf8();
1689    }
1690    TokenizedText { tokens, types }
1691}
1692
1693fn custom_or_regex_token_at(
1694    text: &str,
1695    pos: usize,
1696    options: &DocumentOptions,
1697) -> Option<(String, String, usize)> {
1698    let rest = &text[pos..];
1699    let mut regex_match = None;
1700    for rule in &options.regular_expressions {
1701        let Some(mat) = rule.regex.find(rest) else {
1702            continue;
1703        };
1704        if mat.start() == 0 && mat.end() > 0 {
1705            regex_match = Some((
1706                rest[..mat.end()].to_string(),
1707                rule.token_type.clone(),
1708                pos + mat.end(),
1709            ));
1710        }
1711    }
1712    if regex_match.is_some() {
1713        return regex_match;
1714    }
1715
1716    options
1717        .custom_tokens
1718        .iter()
1719        .filter(|rule| rest.starts_with(&rule.token))
1720        .max_by_key(|rule| rule.token.len())
1721        .map(|rule| {
1722            (
1723                rule.token.clone(),
1724                rule.token_type.clone(),
1725                pos + rule.token.len(),
1726            )
1727        })
1728}
1729
1730fn complex_token_at(text: &str, pos: usize, options: &DocumentOptions) -> Option<(String, usize)> {
1731    let rest = &text[pos..];
1732    if options.detect_patterns.detects_web_address() {
1733        if let Some((token, end)) = web_token_at(text, pos, options) {
1734            return Some((token, end));
1735        }
1736    }
1737    if options.detect_patterns.detects_email_address() {
1738        if let Some((token, end)) = email_token_at(text, pos) {
1739            return Some((token, end));
1740        }
1741    }
1742    if (options.detect_patterns.detects_hashtag() && rest.starts_with('#'))
1743        || (options.detect_patterns.detects_at_mention() && rest.starts_with('@'))
1744    {
1745        let mut end = pos + 1;
1746        while end < text.len() {
1747            let ch = text[end..].chars().next().unwrap();
1748            if ch.is_ascii_alphanumeric() || ch == '_' {
1749                end += ch.len_utf8();
1750            } else {
1751                break;
1752            }
1753        }
1754        if end > pos + 1 {
1755            return Some((text[pos..end].to_string(), end));
1756        }
1757    }
1758    if options.detect_patterns.detects_emoticon()
1759        && (rest.starts_with(":-)")
1760            || rest.starts_with(":-D")
1761            || rest.starts_with(":)")
1762            || rest.starts_with(":D"))
1763    {
1764        let len = if rest.starts_with(":-)") || rest.starts_with(":-D") {
1765            3
1766        } else {
1767            2
1768        };
1769        let end = pos + len;
1770        return Some((text[pos..end].to_string(), end));
1771    }
1772    None
1773}
1774
1775fn web_token_at(text: &str, pos: usize, options: &DocumentOptions) -> Option<(String, usize)> {
1776    let rest = &text[pos..];
1777    if !(has_web_scheme(rest)
1778        || starts_with_ascii_ci(rest, "www.")
1779        || rest
1780            .chars()
1781            .next()
1782            .is_some_and(|ch| ch.is_ascii_alphanumeric()))
1783    {
1784        return None;
1785    }
1786    let end = pos + take_while_nonspace(rest);
1787    let token = text[pos..end].trim_end_matches(is_trailing_punctuation);
1788    if !is_web_address_token(token, options) {
1789        return None;
1790    }
1791    let token_end = pos + token.len();
1792    Some((token.to_string(), token_end))
1793}
1794
1795fn email_token_at(text: &str, pos: usize) -> Option<(String, usize)> {
1796    let rest = &text[pos..];
1797    let at = rest.find('@')?;
1798    if at == 0 {
1799        return None;
1800    }
1801    let local = &rest[..at];
1802    if !local
1803        .chars()
1804        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '%' | '+' | '-'))
1805    {
1806        return None;
1807    }
1808    let after_at = &rest[at + 1..];
1809    let domain_len = after_at
1810        .char_indices()
1811        .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-'))
1812        .map(|(idx, ch)| idx + ch.len_utf8())
1813        .last()?;
1814    let raw_end = pos + at + 1 + domain_len;
1815    let token = text[pos..raw_end].trim_end_matches(is_trailing_punctuation);
1816    if !token.contains('.') || token.ends_with('@') {
1817        return None;
1818    }
1819    let token_end = pos + token.len();
1820    Some((token.to_string(), token_end))
1821}
1822
1823fn is_web_address_token(token: &str, options: &DocumentOptions) -> bool {
1824    if !web_address_candidate(token) {
1825        return false;
1826    }
1827    let Some(host) = web_address_host(token) else {
1828        return false;
1829    };
1830    let has_explicit_prefix = has_web_scheme(token) || starts_with_ascii_ci(token, "www.");
1831    if has_explicit_prefix && !options.top_level_domains_custom {
1832        return domain_host_has_valid_tld_shape(host);
1833    }
1834    domain_host_has_allowed_tld(host, &options.top_level_domains)
1835}
1836
1837fn web_address_host(token: &str) -> Option<&str> {
1838    let stripped = strip_ascii_prefix(token, "http://")
1839        .or_else(|| strip_ascii_prefix(token, "https://"))
1840        .unwrap_or(token);
1841    let host_and_port = stripped
1842        .split(['/', '?', '#'])
1843        .next()
1844        .unwrap_or(stripped)
1845        .trim_end_matches(is_trailing_punctuation);
1846    let host = host_and_port.split(':').next().unwrap_or(host_and_port);
1847    if host.is_empty() || host.contains('@') {
1848        return None;
1849    }
1850    Some(host)
1851}
1852
1853fn web_address_candidate(token: &str) -> bool {
1854    token.contains('.')
1855        && (has_web_scheme(token)
1856            || starts_with_ascii_ci(token, "www.")
1857            || token
1858                .chars()
1859                .next()
1860                .is_some_and(|ch| ch.is_ascii_alphanumeric()))
1861}
1862
1863fn has_web_scheme(text: &str) -> bool {
1864    starts_with_ascii_ci(text, "http://") || starts_with_ascii_ci(text, "https://")
1865}
1866
1867fn starts_with_ascii_ci(text: &str, prefix: &str) -> bool {
1868    text.as_bytes()
1869        .get(..prefix.len())
1870        .is_some_and(|bytes| bytes.eq_ignore_ascii_case(prefix.as_bytes()))
1871}
1872
1873fn strip_ascii_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
1874    starts_with_ascii_ci(text, prefix).then(|| &text[prefix.len()..])
1875}
1876
1877fn domain_host_has_valid_tld_shape(host: &str) -> bool {
1878    domain_host_tld(host).is_some_and(|tld| tld.len() >= 2)
1879}
1880
1881fn domain_host_has_allowed_tld(host: &str, top_level_domains: &[String]) -> bool {
1882    domain_host_tld(host)
1883        .map(|tld| top_level_domains.iter().any(|domain| domain == &tld))
1884        .unwrap_or(false)
1885}
1886
1887fn domain_host_tld(host: &str) -> Option<String> {
1888    let host = host.trim_matches('.');
1889    let mut saw_dot = false;
1890    let mut tld = None;
1891    for label in host.split('.') {
1892        if label.is_empty()
1893            || label.starts_with('-')
1894            || label.ends_with('-')
1895            || !label
1896                .chars()
1897                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1898        {
1899            return None;
1900        }
1901        if tld.is_some() {
1902            saw_dot = true;
1903        }
1904        tld = Some(label);
1905    }
1906    saw_dot.then(|| {
1907        tld.expect("tld present after label loop")
1908            .to_ascii_lowercase()
1909    })
1910}
1911
1912fn default_top_level_domains() -> Vec<String> {
1913    [
1914        "com", "org", "net", "edu", "gov", "mil", "int", "io", "co", "ai", "dev", "app", "info",
1915        "biz", "name", "pro", "us", "uk", "ca", "au", "de", "fr", "jp", "kr", "cn", "in", "br",
1916        "mx", "es", "it", "nl", "se", "no", "fi", "dk", "ch", "at", "be", "ie", "nz", "za", "sg",
1917        "hk", "tw", "ru", "pl", "cz", "me", "tv", "ly", "xyz", "site", "online", "tech",
1918    ]
1919    .into_iter()
1920    .map(str::to_string)
1921    .collect()
1922}
1923
1924fn take_while_nonspace(text: &str) -> usize {
1925    text.char_indices()
1926        .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx))
1927        .unwrap_or(text.len())
1928}
1929
1930fn is_trailing_punctuation(ch: char) -> bool {
1931    matches!(ch, '.' | ',' | ';' | ':' | '!' | '?')
1932}
1933
1934fn tokenized_document_value(
1935    documents: Vec<Vec<String>>,
1936    shape: Vec<usize>,
1937    options: DocumentOptions,
1938    type_details: Option<Vec<Vec<String>>>,
1939) -> BuiltinResult<Value> {
1940    ensure_tokenized_document_class_registered();
1941    let mut object = ObjectInstance::new(TOKENIZED_DOCUMENT_CLASS.to_string());
1942    object
1943        .properties
1944        .insert("Documents".to_string(), documents_cell(&documents)?);
1945    object
1946        .properties
1947        .insert("Vocabulary".to_string(), vocabulary_value(&documents)?);
1948    object.properties.insert(
1949        "NumDocuments".to_string(),
1950        Value::Num(documents.len() as f64),
1951    );
1952    object.properties.insert(
1953        "DocumentLengths".to_string(),
1954        Value::Tensor(
1955            Tensor::new(
1956                documents.iter().map(|doc| doc.len() as f64).collect(),
1957                vec![documents.len(), 1],
1958            )
1959            .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
1960        ),
1961    );
1962    object.properties.insert(
1963        "Shape".to_string(),
1964        Value::Tensor(
1965            Tensor::new(
1966                shape.iter().map(|dim| *dim as f64).collect(),
1967                vec![1, shape.len()],
1968            )
1969            .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
1970        ),
1971    );
1972    object.properties.insert(
1973        "TokenizeMethod".to_string(),
1974        Value::String(
1975            match options.tokenize_method {
1976                TokenizeMethod::Unicode => "unicode",
1977                TokenizeMethod::None => "none",
1978            }
1979            .to_string(),
1980        ),
1981    );
1982    object
1983        .properties
1984        .insert("Language".to_string(), Value::String(options.language));
1985    object.properties.insert(
1986        "DetectPatterns".to_string(),
1987        detect_patterns_value(options.detect_patterns)?,
1988    );
1989    object.properties.insert(
1990        "TopLevelDomains".to_string(),
1991        top_level_domains_value(&options.top_level_domains, "tokenizedDocument")?,
1992    );
1993    object.properties.insert(
1994        "TopLevelDomainsCustom".to_string(),
1995        Value::Bool(options.top_level_domains_custom),
1996    );
1997    if let Some(type_details) = type_details {
1998        object
1999            .properties
2000            .insert("TypeDetails".to_string(), type_details_cell(&type_details)?);
2001    }
2002    Ok(Value::Object(object))
2003}
2004
2005fn type_details_cell(type_details: &[Vec<String>]) -> BuiltinResult<Value> {
2006    let values = type_details
2007        .iter()
2008        .map(|types| {
2009            StringArray::new(types.clone(), vec![1, types.len()])
2010                .map(Value::StringArray)
2011                .map_err(|err| text_analytics_error("tokenizedDocument", err))
2012        })
2013        .collect::<BuiltinResult<Vec<_>>>()?;
2014    Ok(Value::Cell(
2015        CellArray::new(values, type_details.len(), 1)
2016            .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
2017    ))
2018}
2019
2020fn detect_patterns_value(patterns: DetectPatterns) -> BuiltinResult<Value> {
2021    match patterns {
2022        DetectPatterns::All => Ok(Value::String("all".to_string())),
2023        DetectPatterns::None => Ok(Value::String("none".to_string())),
2024        DetectPatterns::Selected(selected) => {
2025            let mut values = Vec::new();
2026            if selected.email_address {
2027                values.push("email-address".to_string());
2028            }
2029            if selected.web_address {
2030                values.push("web-address".to_string());
2031            }
2032            if selected.hashtag {
2033                values.push("hashtag".to_string());
2034            }
2035            if selected.at_mention {
2036                values.push("at-mention".to_string());
2037            }
2038            if selected.emoticon {
2039                values.push("emoticon".to_string());
2040            }
2041            StringArray::new(values.clone(), vec![1, values.len()])
2042                .map(Value::StringArray)
2043                .map_err(|err| text_analytics_error("tokenizedDocument", err))
2044        }
2045    }
2046}
2047
2048pub(in crate::builtins::strings::text_analytics) fn top_level_domains_value(
2049    domains: &[String],
2050    fn_name: &str,
2051) -> BuiltinResult<Value> {
2052    StringArray::new(domains.to_vec(), vec![1, domains.len()])
2053        .map(Value::StringArray)
2054        .map_err(|err| text_analytics_error(fn_name, err))
2055}
2056
2057pub(in crate::builtins::strings::text_analytics) fn tokenized_document_language(
2058    object: &ObjectInstance,
2059) -> String {
2060    match object.properties.get("Language") {
2061        Some(Value::String(value)) => value.clone(),
2062        _ => "en".to_string(),
2063    }
2064}
2065
2066pub(in crate::builtins::strings::text_analytics) fn transform_tokenized_document(
2067    object: &ObjectInstance,
2068    fn_name: &str,
2069    mut transform: impl FnMut(&str, DocumentTokenType) -> BuiltinResult<Option<String>>,
2070) -> BuiltinResult<Value> {
2071    let options = options_from_document_object(object);
2072    let documents = documents_from_object(object, fn_name)?
2073        .into_iter()
2074        .map(|doc| {
2075            doc.into_iter()
2076                .filter_map(|token| {
2077                    match transform(&token, document_token_type_with_options(&token, &options)) {
2078                        Ok(Some(value)) => Some(Ok(value)),
2079                        Ok(None) => None,
2080                        Err(err) => Some(Err(err)),
2081                    }
2082                })
2083                .collect::<BuiltinResult<Vec<_>>>()
2084        })
2085        .collect::<BuiltinResult<Vec<_>>>()?;
2086    tokenized_document_value(documents, shape_from_object(object), options, None)
2087}
2088
2089#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2090pub(in crate::builtins::strings::text_analytics) enum DocumentTokenType {
2091    Letters,
2092    Digits,
2093    Punctuation,
2094    Other,
2095    WebAddress,
2096    EmailAddress,
2097    Hashtag,
2098    AtMention,
2099    Emoticon,
2100    Emoji,
2101}
2102
2103impl DocumentTokenType {
2104    pub(in crate::builtins::strings::text_analytics) fn as_str(self) -> &'static str {
2105        match self {
2106            Self::Letters => "letters",
2107            Self::Digits => "digits",
2108            Self::Punctuation => "punctuation",
2109            Self::Other => "other",
2110            Self::WebAddress => "web-address",
2111            Self::EmailAddress => "email-address",
2112            Self::Hashtag => "hashtag",
2113            Self::AtMention => "at-mention",
2114            Self::Emoticon => "emoticon",
2115            Self::Emoji => "emoji",
2116        }
2117    }
2118}
2119
2120pub(in crate::builtins::strings) fn erase_punctuation_tokenized_document(
2121    object: ObjectInstance,
2122    args: Vec<Value>,
2123) -> BuiltinResult<Value> {
2124    if !object.is_class(TOKENIZED_DOCUMENT_CLASS) {
2125        return Err(text_analytics_error(
2126            "erasePunctuation",
2127            format!(
2128                "erasePunctuation: expected tokenizedDocument object, got {}",
2129                object.class_name
2130            ),
2131        ));
2132    }
2133    let selected_types = parse_erase_punctuation_args(args)?;
2134    transform_tokenized_document(&object, "erasePunctuation", |token, token_type| {
2135        if !selected_types.contains(token_type.as_str()) {
2136            return Ok(Some(token.to_string()));
2137        }
2138        let cleaned = remove_punctuation_and_symbols(token);
2139        Ok((!cleaned.is_empty()).then_some(cleaned))
2140    })
2141}
2142
2143fn parse_erase_punctuation_args(args: Vec<Value>) -> BuiltinResult<HashSet<String>> {
2144    match args.as_slice() {
2145        [] => Ok(["punctuation".to_string(), "other".to_string()]
2146            .into_iter()
2147            .collect()),
2148        [name, types] => {
2149            let option = scalar_text(name, "erasePunctuation")
2150                .map_err(|err| text_analytics_error("erasePunctuation", err.to_string()))?
2151                .to_ascii_lowercase();
2152            if option != "tokentypes" {
2153                return Err(text_analytics_error(
2154                    "erasePunctuation",
2155                    format!("erasePunctuation: unsupported option '{option}'"),
2156                ));
2157            }
2158            let parsed = words_from_word_vector(types, "erasePunctuation")?
2159                .into_iter()
2160                .map(|token_type| token_type.trim().to_ascii_lowercase())
2161                .filter(|token_type| !token_type.is_empty())
2162                .collect::<HashSet<_>>();
2163            if parsed.is_empty() {
2164                return Err(text_analytics_error(
2165                    "erasePunctuation",
2166                    "erasePunctuation: TokenTypes must contain at least one token type",
2167                ));
2168            }
2169            Ok(parsed)
2170        }
2171        _ => Err(text_analytics_error(
2172            "erasePunctuation",
2173            "erasePunctuation: expected erasePunctuation(documents) or erasePunctuation(documents,'TokenTypes',types)",
2174        )),
2175    }
2176}
2177
2178pub(in crate::builtins::strings::text_analytics) fn document_token_type(
2179    token: &str,
2180) -> DocumentTokenType {
2181    document_token_type_with_options(token, &DEFAULT_DOCUMENT_OPTIONS)
2182}
2183
2184pub(in crate::builtins::strings::text_analytics) fn document_token_type_with_options(
2185    token: &str,
2186    options: &DocumentOptions,
2187) -> DocumentTokenType {
2188    if is_web_address_token(token, options) {
2189        return DocumentTokenType::WebAddress;
2190    }
2191    if email_token_at(token, 0).is_some_and(|(_, end)| end == token.len()) {
2192        return DocumentTokenType::EmailAddress;
2193    }
2194    if let Some(tag) = token.strip_prefix('#') {
2195        if !tag.is_empty()
2196            && tag
2197                .chars()
2198                .next()
2199                .is_some_and(|ch| ch.is_ascii_alphabetic())
2200            && tag
2201                .chars()
2202                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2203        {
2204            return DocumentTokenType::Hashtag;
2205        }
2206    }
2207    if let Some(mention) = token.strip_prefix('@') {
2208        let len = mention.chars().count();
2209        if (1..=15).contains(&len)
2210            && mention
2211                .chars()
2212                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2213        {
2214            return DocumentTokenType::AtMention;
2215        }
2216    }
2217    if matches!(token, ":-)" | ":-D" | ":)" | ":D") {
2218        return DocumentTokenType::Emoticon;
2219    }
2220    if !token.is_empty() && token.chars().all(char::is_numeric) {
2221        return DocumentTokenType::Digits;
2222    }
2223    if !token.is_empty() && token.chars().all(is_emoji_char) {
2224        return DocumentTokenType::Emoji;
2225    }
2226    if token
2227        .chars()
2228        .all(|ch| !ch.is_alphanumeric() && !ch.is_whitespace())
2229    {
2230        return DocumentTokenType::Punctuation;
2231    }
2232    if token.chars().any(char::is_alphabetic)
2233        && token
2234            .chars()
2235            .all(|ch| ch.is_alphabetic() || ch == '\'' || ch == '-' || ch == '_')
2236    {
2237        return DocumentTokenType::Letters;
2238    }
2239    DocumentTokenType::Other
2240}
2241
2242fn is_emoji_char(ch: char) -> bool {
2243    matches!(
2244        ch as u32,
2245        0x1F300..=0x1FAFF | 0x2600..=0x27BF | 0x2300..=0x23FF | 0xFE0F
2246    )
2247}
2248
2249static DEFAULT_DOCUMENT_OPTIONS: Lazy<DocumentOptions> = Lazy::new(DocumentOptions::default);
2250
2251fn remove_punctuation_and_symbols(text: &str) -> String {
2252    static PUNCTUATION_OR_SYMBOL: Lazy<Regex> =
2253        Lazy::new(|| Regex::new(r"[\p{P}\p{S}]").expect("valid punctuation regex"));
2254    PUNCTUATION_OR_SYMBOL.replace_all(text, "").to_string()
2255}
2256
2257fn stop_words_language_from_document_object(
2258    object: &ObjectInstance,
2259    fn_name: &str,
2260) -> BuiltinResult<StopWordsLanguage> {
2261    match tokenized_document_language(object)
2262        .trim()
2263        .to_ascii_lowercase()
2264        .as_str()
2265    {
2266        "en" => Ok(StopWordsLanguage::English),
2267        "de" => Ok(StopWordsLanguage::German),
2268        "ja" => Ok(StopWordsLanguage::Japanese),
2269        "ko" => Ok(StopWordsLanguage::Korean),
2270        other => Err(text_analytics_error(
2271            fn_name,
2272            format!("{fn_name}: unsupported document language '{other}'"),
2273        )),
2274    }
2275}
2276
2277fn parse_bool_scalar(value: &Value, fn_name: &str) -> BuiltinResult<bool> {
2278    match value {
2279        Value::Bool(value) => Ok(*value),
2280        Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
2281        Value::Tensor(tensor) if tensor.data.len() == 1 => match tensor.data[0] {
2282            0.0 => Ok(false),
2283            1.0 => Ok(true),
2284            other => Err(text_analytics_error(
2285                fn_name,
2286                format!("{fn_name}: logical scalar option must be true or false, got {other}"),
2287            )),
2288        },
2289        other => Err(text_analytics_error(
2290            fn_name,
2291            format!("{fn_name}: logical scalar option must be true or false, got {other:?}"),
2292        )),
2293    }
2294}
2295
2296fn documents_cell(documents: &[Vec<String>]) -> BuiltinResult<Value> {
2297    let values = documents
2298        .iter()
2299        .map(|doc| {
2300            StringArray::new(doc.clone(), vec![1, doc.len()])
2301                .map(Value::StringArray)
2302                .map_err(|err| text_analytics_error("tokenizedDocument", err))
2303        })
2304        .collect::<BuiltinResult<Vec<_>>>()?;
2305    Ok(Value::Cell(
2306        CellArray::new(values, documents.len(), 1)
2307            .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
2308    ))
2309}
2310
2311fn vocabulary_value(documents: &[Vec<String>]) -> BuiltinResult<Value> {
2312    let mut seen = HashSet::new();
2313    let mut words = Vec::new();
2314    for token in documents.iter().flatten() {
2315        if seen.insert(token.clone()) {
2316            words.push(token.clone());
2317        }
2318    }
2319    StringArray::new(words.clone(), vec![1, words.len()])
2320        .map(Value::StringArray)
2321        .map_err(|err| text_analytics_error("tokenizedDocument", err))
2322}
2323
2324pub(in crate::builtins::strings::text_analytics) fn documents_from_object(
2325    object: &ObjectInstance,
2326    fn_name: &str,
2327) -> BuiltinResult<Vec<Vec<String>>> {
2328    let Some(Value::Cell(cell)) = object.properties.get("Documents") else {
2329        return Err(text_analytics_error(
2330            fn_name,
2331            format!("{fn_name}: tokenizedDocument object missing Documents property"),
2332        ));
2333    };
2334    let mut documents = Vec::with_capacity(cell.data.len());
2335    for item in &cell.data {
2336        documents.push(words_from_word_vector(item, fn_name)?);
2337    }
2338    Ok(documents)
2339}
2340
2341pub(in crate::builtins::strings::text_analytics) fn replace_tokenized_document_documents(
2342    object: &mut ObjectInstance,
2343    documents: Vec<Vec<String>>,
2344    fn_name: &str,
2345) -> BuiltinResult<()> {
2346    let shape = document_shape_from_object(object, fn_name)?;
2347    let expected = shape.iter().try_fold(1usize, |acc, dim| {
2348        acc.checked_mul(*dim).ok_or_else(|| {
2349            text_analytics_error(
2350                fn_name,
2351                format!("{fn_name}: tokenizedDocument Shape property overflows element count"),
2352            )
2353        })
2354    })?;
2355    if expected != documents.len() {
2356        return Err(text_analytics_error(
2357            fn_name,
2358            format!(
2359                "{fn_name}: replacement documents has {} documents but Shape requires {expected}",
2360                documents.len()
2361            ),
2362        ));
2363    }
2364    object
2365        .properties
2366        .insert("Documents".to_string(), documents_cell(&documents)?);
2367    object
2368        .properties
2369        .insert("Vocabulary".to_string(), vocabulary_value(&documents)?);
2370    object.properties.insert(
2371        "NumDocuments".to_string(),
2372        Value::Num(documents.len() as f64),
2373    );
2374    object.properties.insert(
2375        "DocumentLengths".to_string(),
2376        Value::Tensor(
2377            Tensor::new(
2378                documents.iter().map(|doc| doc.len() as f64).collect(),
2379                vec![documents.len(), 1],
2380            )
2381            .map_err(|err| text_analytics_error(fn_name, err))?,
2382        ),
2383    );
2384    Ok(())
2385}
2386
2387pub(in crate::builtins::strings::text_analytics) fn document_shape_from_object(
2388    object: &ObjectInstance,
2389    fn_name: &str,
2390) -> BuiltinResult<Vec<usize>> {
2391    let expected = object
2392        .properties
2393        .get("Documents")
2394        .and_then(|value| match value {
2395            Value::Cell(cell) => Some(cell.data.len()),
2396            _ => None,
2397        })
2398        .or_else(|| {
2399            object
2400                .properties
2401                .get("NumDocuments")
2402                .and_then(|value| match value {
2403                    Value::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as usize),
2404                    _ => None,
2405                })
2406        })
2407        .unwrap_or(1);
2408    let shape = shape_from_object(object);
2409    let cells = shape.iter().try_fold(1usize, |acc, dim| {
2410        acc.checked_mul(*dim).ok_or_else(|| {
2411            text_analytics_error(
2412                fn_name,
2413                format!("{fn_name}: tokenizedDocument Shape property overflows element count"),
2414            )
2415        })
2416    })?;
2417    if cells != expected {
2418        return Err(text_analytics_error(
2419            fn_name,
2420            format!(
2421                "{fn_name}: tokenizedDocument Shape property has {cells} elements but Documents has {expected}"
2422            ),
2423        ));
2424    }
2425    Ok(shape)
2426}
2427
2428fn shape_from_object(object: &ObjectInstance) -> Vec<usize> {
2429    if let Some(Value::Tensor(tensor)) = object.properties.get("Shape") {
2430        tensor.data.iter().map(|value| *value as usize).collect()
2431    } else {
2432        vec![
2433            object
2434                .properties
2435                .get("NumDocuments")
2436                .and_then(|value| match value {
2437                    Value::Num(n) => Some(*n as usize),
2438                    _ => None,
2439                })
2440                .unwrap_or(1),
2441            1,
2442        ]
2443    }
2444}
2445
2446pub(in crate::builtins::strings::text_analytics) fn options_from_document_object(
2447    object: &ObjectInstance,
2448) -> DocumentOptions {
2449    let tokenize_method = match object.properties.get("TokenizeMethod") {
2450        Some(Value::String(value)) if value == "none" => TokenizeMethod::None,
2451        _ => TokenizeMethod::Unicode,
2452    };
2453    let language = match object.properties.get("Language") {
2454        Some(Value::String(value)) => value.clone(),
2455        _ => "en".to_string(),
2456    };
2457    let detect_patterns = object
2458        .properties
2459        .get("DetectPatterns")
2460        .and_then(|value| parse_detect_patterns(value, "tokenizedDocument").ok())
2461        .unwrap_or(DetectPatterns::All);
2462    let top_level_domains = object
2463        .properties
2464        .get("TopLevelDomains")
2465        .and_then(|value| parse_top_level_domains(value, "tokenizedDocument").ok())
2466        .unwrap_or_else(default_top_level_domains);
2467    let top_level_domains_custom = match object.properties.get("TopLevelDomainsCustom") {
2468        Some(Value::Bool(value)) => *value,
2469        _ => false,
2470    };
2471    DocumentOptions {
2472        tokenize_method,
2473        language,
2474        detect_patterns,
2475        top_level_domains,
2476        top_level_domains_custom,
2477        custom_tokens: Vec::new(),
2478        regular_expressions: Vec::new(),
2479    }
2480}
2481
2482fn bag_from_documents(documents: Vec<Vec<String>>) -> BuiltinResult<Value> {
2483    let mut vocabulary = Vec::new();
2484    let mut positions = BTreeMap::new();
2485    for token in documents.iter().flatten() {
2486        if !positions.contains_key(token) {
2487            positions.insert(token.clone(), vocabulary.len());
2488            vocabulary.push(token.clone());
2489        }
2490    }
2491    let rows = documents.len();
2492    let cols = vocabulary.len();
2493    let mut counts = vec![0.0; checked_count_len(rows, cols, "bagOfWords")?];
2494    for (doc_idx, doc) in documents.iter().enumerate() {
2495        for token in doc {
2496            if let Some(col) = positions.get(token) {
2497                counts[doc_idx + col * rows] += 1.0;
2498            }
2499        }
2500    }
2501    bag_object(vocabulary, counts, rows)
2502}
2503
2504fn bag_from_unique_words_and_counts(words: &Value, counts: &Value) -> BuiltinResult<Value> {
2505    let raw_words = words_from_word_vector_preserving_missing(words, "bagOfWords")?;
2506    let Value::Tensor(tensor) = counts else {
2507        return Err(text_analytics_error(
2508            "bagOfWords",
2509            format!("bagOfWords: counts must be a numeric matrix, got {counts:?}"),
2510        ));
2511    };
2512    if tensor.cols != raw_words.len() {
2513        return Err(text_analytics_error(
2514            "bagOfWords",
2515            format!(
2516                "bagOfWords: counts columns ({}) must match uniqueWords length ({})",
2517                tensor.cols,
2518                raw_words.len()
2519            ),
2520        ));
2521    }
2522    if tensor
2523        .data
2524        .iter()
2525        .any(|value| !value.is_finite() || *value < 0.0 || value.fract() != 0.0)
2526    {
2527        return Err(text_analytics_error(
2528            "bagOfWords",
2529            "bagOfWords: counts must be nonnegative integers",
2530        ));
2531    }
2532    let mut seen = HashSet::new();
2533    let mut vocabulary = Vec::new();
2534    let mut keep_cols = Vec::new();
2535    for (col, word) in raw_words.iter().enumerate() {
2536        if is_missing_string(word) {
2537            continue;
2538        }
2539        if !seen.insert(word.clone()) {
2540            return Err(text_analytics_error(
2541                "bagOfWords",
2542                format!("bagOfWords: uniqueWords contains duplicate word '{word}'"),
2543            ));
2544        }
2545        vocabulary.push(word.clone());
2546        keep_cols.push(col);
2547    }
2548    let mut filtered_counts = Vec::with_capacity(checked_count_len(
2549        tensor.rows,
2550        keep_cols.len(),
2551        "bagOfWords",
2552    )?);
2553    for col in keep_cols {
2554        for row in 0..tensor.rows {
2555            filtered_counts.push(tensor.data[row + col * tensor.rows]);
2556        }
2557    }
2558    bag_object(vocabulary, filtered_counts, tensor.rows)
2559}
2560
2561fn bag_object(vocabulary: Vec<String>, counts: Vec<f64>, rows: usize) -> BuiltinResult<Value> {
2562    ensure_bag_of_words_class_registered();
2563    let cols = vocabulary.len();
2564    let expected = checked_count_len(rows, cols, "bagOfWords")?;
2565    if counts.len() != expected {
2566        return Err(text_analytics_error(
2567            "bagOfWords",
2568            format!(
2569                "bagOfWords: count storage has {} values but expected {} for a {}x{} model",
2570                counts.len(),
2571                expected,
2572                rows,
2573                cols
2574            ),
2575        ));
2576    }
2577    let mut object = ObjectInstance::new(BAG_OF_WORDS_CLASS.to_string());
2578    object.properties.insert(
2579        "Vocabulary".to_string(),
2580        Value::StringArray(
2581            StringArray::new(vocabulary.clone(), vec![1, vocabulary.len()])
2582                .map_err(|err| text_analytics_error("bagOfWords", err))?,
2583        ),
2584    );
2585    object.properties.insert(
2586        "Counts".to_string(),
2587        Value::Tensor(
2588            Tensor::new(counts, vec![rows, cols])
2589                .map_err(|err| text_analytics_error("bagOfWords", err))?,
2590        ),
2591    );
2592    object
2593        .properties
2594        .insert("NumWords".to_string(), Value::Num(cols as f64));
2595    object
2596        .properties
2597        .insert("NumDocuments".to_string(), Value::Num(rows as f64));
2598    Ok(Value::Object(object))
2599}
2600
2601pub(in crate::builtins::strings::text_analytics) fn checked_count_len(
2602    rows: usize,
2603    cols: usize,
2604    fn_name: &str,
2605) -> BuiltinResult<usize> {
2606    let len = rows.checked_mul(cols).ok_or_else(|| {
2607        text_analytics_error(
2608            fn_name,
2609            format!("{fn_name}: bag count matrix dimensions overflow"),
2610        )
2611    })?;
2612    if len > MAX_DENSE_BAG_COUNT_CELLS {
2613        return Err(text_analytics_error(
2614            fn_name,
2615            format!(
2616                "{fn_name}: dense bag count matrix would require {len} values; sparse bag storage remains tracked"
2617            ),
2618        ));
2619    }
2620    Ok(len)
2621}
2622
2623fn remove_short_words_from_bag(object: ObjectInstance, max_len: usize) -> BuiltinResult<Value> {
2624    filter_bag_columns_by_predicate(object, "removeShortWords", |word| {
2625        word.chars().count() > max_len
2626    })
2627}
2628
2629fn filter_bag_columns_by_predicate(
2630    object: ObjectInstance,
2631    fn_name: &str,
2632    mut keep_word: impl FnMut(&str) -> bool,
2633) -> BuiltinResult<Value> {
2634    filter_bag_columns(object, fn_name, |word| keep_word(word))
2635}
2636
2637fn filter_bag_columns(
2638    object: ObjectInstance,
2639    fn_name: &str,
2640    mut keep_word: impl FnMut(&str) -> bool,
2641) -> BuiltinResult<Value> {
2642    let vocabulary = vocabulary_from_bag(&object, fn_name)?;
2643    let counts = counts_from_bag(&object, fn_name)?;
2644    let keep = vocabulary
2645        .iter()
2646        .enumerate()
2647        .filter_map(|(idx, word)| keep_word(word).then_some(idx))
2648        .collect::<Vec<_>>();
2649    let mut new_vocab = Vec::with_capacity(keep.len());
2650    let mut new_counts = Vec::with_capacity(counts.rows * keep.len());
2651    for col in keep {
2652        new_vocab.push(vocabulary[col].clone());
2653        for row in 0..counts.rows {
2654            new_counts.push(counts.data[row + col * counts.rows]);
2655        }
2656    }
2657    bag_object(new_vocab, new_counts, counts.rows)
2658}
2659
2660fn documents_vocabulary(object: &ObjectInstance, fn_name: &str) -> BuiltinResult<Vec<String>> {
2661    match object.properties.get("Vocabulary") {
2662        Some(value) => words_from_word_vector(value, fn_name),
2663        None => Ok(vocabulary_from_documents(&documents_from_object(
2664            object, fn_name,
2665        )?)),
2666    }
2667}
2668
2669fn vocabulary_from_documents(documents: &[Vec<String>]) -> Vec<String> {
2670    let mut seen = HashSet::new();
2671    let mut vocabulary = Vec::new();
2672    for token in documents.iter().flatten() {
2673        if seen.insert(token.clone()) {
2674            vocabulary.push(token.clone());
2675        }
2676    }
2677    vocabulary
2678}
2679
2680pub(in crate::builtins::strings::text_analytics) fn vocabulary_from_bag(
2681    object: &ObjectInstance,
2682    fn_name: &str,
2683) -> BuiltinResult<Vec<String>> {
2684    match object.properties.get("Vocabulary") {
2685        Some(value) => words_from_word_vector(value, fn_name),
2686        None => Err(text_analytics_error(
2687            fn_name,
2688            format!("{fn_name}: bagOfWords object missing Vocabulary property"),
2689        )),
2690    }
2691}
2692
2693pub(in crate::builtins::strings::text_analytics) fn counts_from_bag(
2694    object: &ObjectInstance,
2695    fn_name: &str,
2696) -> BuiltinResult<Tensor> {
2697    match object.properties.get("Counts") {
2698        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
2699        _ => Err(text_analytics_error(
2700            fn_name,
2701            format!("{fn_name}: bagOfWords object missing Counts property"),
2702        )),
2703    }
2704}
2705
2706pub(in crate::builtins::strings::text_analytics) fn words_from_word_vector(
2707    value: &Value,
2708    fn_name: &str,
2709) -> BuiltinResult<Vec<String>> {
2710    match value {
2711        Value::String(text) => Ok(vec![text.clone()]),
2712        Value::StringArray(array) => Ok(array
2713            .data
2714            .iter()
2715            .filter(|text| !is_missing_string(text))
2716            .cloned()
2717            .collect()),
2718        Value::CharArray(array) if array.rows <= 1 => {
2719            let text = if array.rows == 0 {
2720                String::new()
2721            } else {
2722                char_row_to_string_slice(&array.data, array.cols, 0)
2723            };
2724            Ok(vec![text])
2725        }
2726        Value::CharArray(array) => {
2727            let mut words = Vec::with_capacity(array.rows);
2728            for row in 0..array.rows {
2729                words.push(char_row_to_string_slice(&array.data, array.cols, row));
2730            }
2731            Ok(words)
2732        }
2733        Value::Cell(cell) => cell
2734            .data
2735            .iter()
2736            .map(|item| {
2737                scalar_text(item, fn_name)
2738                    .map_err(|err| text_analytics_error(fn_name, err.to_string()))
2739            })
2740            .collect(),
2741        other => Err(text_analytics_error(
2742            fn_name,
2743            format!("{fn_name}: expected word vector, got {other:?}"),
2744        )),
2745    }
2746}
2747
2748pub(in crate::builtins::strings::text_analytics) fn words_from_word_vector_preserving_missing(
2749    value: &Value,
2750    fn_name: &str,
2751) -> BuiltinResult<Vec<String>> {
2752    match value {
2753        Value::String(text) => Ok(vec![text.clone()]),
2754        Value::StringArray(array) => Ok(array.data.clone()),
2755        Value::CharArray(array) if array.rows <= 1 => {
2756            let text = if array.rows == 0 {
2757                String::new()
2758            } else {
2759                char_row_to_string_slice(&array.data, array.cols, 0)
2760            };
2761            Ok(vec![text])
2762        }
2763        Value::CharArray(array) => {
2764            let mut words = Vec::with_capacity(array.rows);
2765            for row in 0..array.rows {
2766                words.push(char_row_to_string_slice(&array.data, array.cols, row));
2767            }
2768            Ok(words)
2769        }
2770        Value::Cell(cell) => cell
2771            .data
2772            .iter()
2773            .map(|item| {
2774                scalar_text(item, fn_name)
2775                    .map_err(|err| text_analytics_error(fn_name, err.to_string()))
2776            })
2777            .collect(),
2778        other => Err(text_analytics_error(
2779            fn_name,
2780            format!("{fn_name}: expected word vector, got {other:?}"),
2781        )),
2782    }
2783}
2784
2785fn parse_positive_integer(value: &Value, fn_name: &str) -> BuiltinResult<usize> {
2786    let n = match value {
2787        Value::Num(n) => *n,
2788        Value::Tensor(tensor) if tensor.data.len() == 1 => tensor.data[0],
2789        other => {
2790            return Err(text_analytics_error(
2791                fn_name,
2792                format!("{fn_name}: length must be a positive integer scalar, got {other:?}"),
2793            ))
2794        }
2795    };
2796    if !n.is_finite() || n <= 0.0 || n.fract() != 0.0 {
2797        return Err(text_analytics_error(
2798            fn_name,
2799            format!("{fn_name}: length must be a positive integer, got {n}"),
2800        ));
2801    }
2802    Ok(n as usize)
2803}
2804
2805#[cfg(test)]
2806mod tests {
2807    use super::*;
2808    use crate::builtins::table::table_from_columns;
2809
2810    fn run_tokenized(args: Vec<Value>) -> BuiltinResult<Value> {
2811        futures::executor::block_on(tokenized_document_builtin(args))
2812    }
2813
2814    fn run_bag(args: Vec<Value>) -> BuiltinResult<Value> {
2815        futures::executor::block_on(bag_of_words_builtin(args))
2816    }
2817
2818    fn run_remove_short(value: Value, len: Value) -> BuiltinResult<Value> {
2819        futures::executor::block_on(remove_short_words_builtin(value, len))
2820    }
2821
2822    fn run_remove_long(value: Value, len: Value) -> BuiltinResult<Value> {
2823        futures::executor::block_on(remove_long_words_builtin(value, len))
2824    }
2825
2826    fn run_remove_words(args: Vec<Value>) -> BuiltinResult<Value> {
2827        futures::executor::block_on(remove_words_builtin(args))
2828    }
2829
2830    fn run_remove_stop(args: Vec<Value>) -> BuiltinResult<Value> {
2831        futures::executor::block_on(remove_stop_words_builtin(args))
2832    }
2833
2834    fn object(value: Value) -> ObjectInstance {
2835        let Value::Object(object) = value else {
2836            panic!("expected object");
2837        };
2838        object
2839    }
2840
2841    fn string_array_property(object: &ObjectInstance, name: &str) -> Vec<String> {
2842        let Some(Value::StringArray(array)) = object.properties.get(name) else {
2843            panic!("expected string array property {name}");
2844        };
2845        array.data.clone()
2846    }
2847
2848    fn tensor_property(object: &ObjectInstance, name: &str) -> Tensor {
2849        let Some(Value::Tensor(tensor)) = object.properties.get(name) else {
2850            panic!("expected tensor property {name}");
2851        };
2852        tensor.clone()
2853    }
2854
2855    fn documents_property(object: &ObjectInstance) -> Vec<Vec<String>> {
2856        documents_from_object(object, "test").expect("documents property")
2857    }
2858
2859    fn type_details_property(object: &ObjectInstance) -> Vec<Vec<String>> {
2860        let Some(Value::Cell(cell)) = object.properties.get("TypeDetails") else {
2861            panic!("expected TypeDetails property");
2862        };
2863        cell.data
2864            .iter()
2865            .map(|value| {
2866                let Value::StringArray(array) = value else {
2867                    panic!("expected TypeDetails string array");
2868                };
2869                array.data.clone()
2870            })
2871            .collect()
2872    }
2873
2874    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2875    #[test]
2876    fn tokenized_document_tokenizes_string_array_and_vocabulary() {
2877        let input = StringArray::new(
2878            vec![
2879                "an example of a short sentence".to_string(),
2880                "a second short sentence".to_string(),
2881            ],
2882            vec![2, 1],
2883        )
2884        .unwrap();
2885        let doc = object(run_tokenized(vec![Value::StringArray(input)]).expect("tokenized"));
2886        assert_eq!(doc.class_name, TOKENIZED_DOCUMENT_CLASS);
2887        assert_eq!(doc.properties.get("NumDocuments"), Some(&Value::Num(2.0)));
2888        assert_eq!(
2889            string_array_property(&doc, "Vocabulary"),
2890            vec!["an", "example", "of", "a", "short", "sentence", "second"]
2891        );
2892        let lengths = tensor_property(&doc, "DocumentLengths");
2893        assert_eq!(lengths.data, vec![6.0, 4.0]);
2894        let shape = tensor_property(&doc, "Shape");
2895        assert_eq!(shape.data, vec![2.0, 1.0]);
2896    }
2897
2898    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2899    #[test]
2900    fn tokenized_document_none_accepts_pretokenized_words() {
2901        let input = StringArray::new(
2902            vec![
2903                "For".to_string(),
2904                "more".to_string(),
2905                "information".to_string(),
2906            ],
2907            vec![1, 3],
2908        )
2909        .unwrap();
2910        let doc = object(
2911            run_tokenized(vec![
2912                Value::StringArray(input),
2913                Value::String("TokenizeMethod".to_string()),
2914                Value::String("none".to_string()),
2915            ])
2916            .expect("tokenized"),
2917        );
2918        assert_eq!(
2919            string_array_property(&doc, "Vocabulary"),
2920            vec!["For", "more", "information"]
2921        );
2922    }
2923
2924    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2925    #[test]
2926    fn tokenized_document_no_input_is_scalar_empty_document() {
2927        let doc = object(run_tokenized(Vec::new()).expect("tokenized"));
2928        assert_eq!(doc.properties.get("NumDocuments"), Some(&Value::Num(1.0)));
2929        assert_eq!(
2930            string_array_property(&doc, "Vocabulary"),
2931            Vec::<String>::new()
2932        );
2933        let lengths = tensor_property(&doc, "DocumentLengths");
2934        assert_eq!(lengths.shape, vec![1, 1]);
2935        assert_eq!(lengths.data, vec![0.0]);
2936        let shape = tensor_property(&doc, "Shape");
2937        assert_eq!(shape.data, vec![1.0, 1.0]);
2938
2939        let bag = object(run_bag(vec![Value::Object(doc)]).expect("bag"));
2940        assert_eq!(bag.properties.get("NumDocuments"), Some(&Value::Num(1.0)));
2941        assert_eq!(bag.properties.get("NumWords"), Some(&Value::Num(0.0)));
2942        let counts = tensor_property(&bag, "Counts");
2943        assert_eq!(counts.shape, vec![1, 0]);
2944        assert!(counts.data.is_empty());
2945    }
2946
2947    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2948    #[test]
2949    fn tokenized_document_detects_complex_tokens_by_default() {
2950        let doc = object(
2951            run_tokenized(vec![Value::String(
2952                "Analyze #MATLAB :-) at help@example.com or https://www.mathworks.com/help/"
2953                    .to_string(),
2954            )])
2955            .expect("tokenized"),
2956        );
2957        let vocabulary = string_array_property(&doc, "Vocabulary");
2958        assert!(vocabulary.contains(&"#MATLAB".to_string()));
2959        assert!(vocabulary.contains(&":-)".to_string()));
2960        assert!(vocabulary.contains(&"help@example.com".to_string()));
2961        assert!(vocabulary.contains(&"https://www.mathworks.com/help/".to_string()));
2962    }
2963
2964    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2965    #[test]
2966    fn tokenized_document_preserves_trailing_punctuation_after_url_token() {
2967        let doc = object(
2968            run_tokenized(vec![Value::String(
2969                "Visit https://example.com.".to_string(),
2970            )])
2971            .expect("tokenized"),
2972        );
2973        let vocabulary = string_array_property(&doc, "Vocabulary");
2974        assert!(vocabulary.contains(&"https://example.com".to_string()));
2975        assert!(vocabulary.contains(&".".to_string()));
2976    }
2977
2978    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2979    #[test]
2980    fn tokenized_document_preserves_explicit_urls_with_uncommon_default_tlds() {
2981        let doc = object(
2982            run_tokenized(vec![Value::String(
2983                "Visit HTTPS://example.software and www.example.community.".to_string(),
2984            )])
2985            .expect("tokenized"),
2986        );
2987        assert_eq!(
2988            documents_property(&doc),
2989            vec![vec![
2990                "Visit",
2991                "HTTPS://example.software",
2992                "and",
2993                "www.example.community",
2994                "."
2995            ]]
2996        );
2997    }
2998
2999    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3000    #[test]
3001    fn tokenized_document_detects_bare_domains_with_configured_tlds() {
3002        let tlds = StringArray::new(vec!["zz".into(), "dev".into()], vec![1, 2]).unwrap();
3003        let doc = object(
3004            run_tokenized(vec![
3005                Value::String("See example.zz, docs.example.dev/path and example.com".to_string()),
3006                Value::String("TopLevelDomains".to_string()),
3007                Value::StringArray(tlds),
3008            ])
3009            .expect("tokenized"),
3010        );
3011        assert_eq!(
3012            documents_property(&doc),
3013            vec![vec![
3014                "See",
3015                "example.zz",
3016                ",",
3017                "docs.example.dev/path",
3018                "and",
3019                "example",
3020                ".",
3021                "com"
3022            ]]
3023        );
3024        assert_eq!(
3025            string_array_property(&doc, "TopLevelDomains"),
3026            vec!["zz", "dev"]
3027        );
3028    }
3029
3030    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3031    #[test]
3032    fn tokenized_document_respects_detect_patterns_subset() {
3033        let patterns =
3034            StringArray::new(vec!["hashtag".into(), "email-address".into()], vec![1, 2]).unwrap();
3035        let doc = object(
3036            run_tokenized(vec![
3037                Value::String("Mail a@example.com #MATLAB at https://example.com :-D".to_string()),
3038                Value::String("DetectPatterns".to_string()),
3039                Value::StringArray(patterns),
3040            ])
3041            .expect("tokenized"),
3042        );
3043        assert_eq!(
3044            documents_property(&doc),
3045            vec![vec![
3046                "Mail",
3047                "a@example.com",
3048                "#MATLAB",
3049                "at",
3050                "https",
3051                ":",
3052                "/",
3053                "/",
3054                "example",
3055                ".",
3056                "com",
3057                ":",
3058                "-",
3059                "D"
3060            ]]
3061        );
3062    }
3063
3064    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3065    #[test]
3066    fn tokenized_document_supports_custom_tokens_and_longest_conflict() {
3067        let custom = StringArray::new(vec!["C++".into(), "C++17".into()], vec![1, 2]).unwrap();
3068        let doc = object(
3069            run_tokenized(vec![
3070                Value::String("Use C++17 and C++.".to_string()),
3071                Value::String("CustomTokens".to_string()),
3072                Value::StringArray(custom),
3073            ])
3074            .expect("tokenized"),
3075        );
3076        assert_eq!(
3077            documents_property(&doc),
3078            vec![vec!["Use", "C++17", "and", "C++", "."]]
3079        );
3080        assert_eq!(
3081            type_details_property(&doc),
3082            vec![vec![
3083                "letters",
3084                "custom",
3085                "letters",
3086                "custom",
3087                "punctuation"
3088            ]]
3089        );
3090    }
3091
3092    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3093    #[test]
3094    fn tokenized_document_supports_custom_token_table_types() {
3095        let table = table_from_columns(
3096            vec!["Token".into(), "Type".into()],
3097            vec![
3098                Value::StringArray(
3099                    StringArray::new(vec!["Na+".into(), "H2O".into()], vec![2, 1]).unwrap(),
3100                ),
3101                Value::StringArray(
3102                    StringArray::new(vec!["ion".into(), "formula".into()], vec![2, 1]).unwrap(),
3103                ),
3104            ],
3105        )
3106        .expect("custom table");
3107        let doc = object(
3108            run_tokenized(vec![
3109                Value::String("Na+ in H2O".to_string()),
3110                Value::String("CustomTokens".to_string()),
3111                table,
3112            ])
3113            .expect("tokenized"),
3114        );
3115        assert_eq!(documents_property(&doc), vec![vec!["Na+", "in", "H2O"]]);
3116        assert_eq!(
3117            type_details_property(&doc),
3118            vec![vec!["ion", "letters", "formula"]]
3119        );
3120    }
3121
3122    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3123    #[test]
3124    fn tokenized_document_supports_regex_table_and_regex_precedence() {
3125        let custom = StringArray::new(vec!["abc123".into()], vec![1, 1]).unwrap();
3126        let regex_table = table_from_columns(
3127            vec!["Pattern".into(), "Type".into()],
3128            vec![
3129                Value::StringArray(
3130                    StringArray::new(vec![r"[a-z]+\d+".into(), r"abc\d+".into()], vec![2, 1])
3131                        .unwrap(),
3132                ),
3133                Value::StringArray(
3134                    StringArray::new(vec!["alnum".into(), "code".into()], vec![2, 1]).unwrap(),
3135                ),
3136            ],
3137        )
3138        .expect("regex table");
3139        let doc = object(
3140            run_tokenized(vec![
3141                Value::String("abc123 xyz9".to_string()),
3142                Value::String("CustomTokens".to_string()),
3143                Value::StringArray(custom),
3144                Value::String("RegularExpressions".to_string()),
3145                regex_table,
3146            ])
3147            .expect("tokenized"),
3148        );
3149        assert_eq!(documents_property(&doc), vec![vec!["abc123", "xyz9"]]);
3150        assert_eq!(type_details_property(&doc), vec![vec!["code", "alnum"]]);
3151    }
3152
3153    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3154    #[test]
3155    fn tokenized_document_supports_non_table_custom_and_regex_vectors() {
3156        let custom = CellArray::new(
3157            vec![
3158                Value::String("R&D".to_string()),
3159                Value::String("C#".to_string()),
3160            ],
3161            1,
3162            2,
3163        )
3164        .unwrap();
3165        let regexes = StringArray::new(vec![r"\d{4}-\d{2}-\d{2}".into()], vec![1, 1]).unwrap();
3166        let doc = object(
3167            run_tokenized(vec![
3168                Value::String("R&D shipped C# on 2026-07-17.".to_string()),
3169                Value::String("CustomTokens".to_string()),
3170                Value::Cell(custom),
3171                Value::String("RegularExpressions".to_string()),
3172                Value::StringArray(regexes),
3173            ])
3174            .expect("tokenized"),
3175        );
3176
3177        assert_eq!(
3178            documents_property(&doc),
3179            vec![vec!["R&D", "shipped", "C#", "on", "2026-07-17", "."]]
3180        );
3181        assert_eq!(
3182            type_details_property(&doc),
3183            vec![vec![
3184                "custom",
3185                "letters",
3186                "custom",
3187                "letters",
3188                "custom",
3189                "punctuation"
3190            ]]
3191        );
3192    }
3193
3194    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3195    #[test]
3196    fn tokenized_document_rejects_invalid_custom_and_regex_options() {
3197        let err = run_tokenized(vec![
3198            Value::String("abc".to_string()),
3199            Value::String("RegularExpressions".to_string()),
3200            Value::String("(".to_string()),
3201        ])
3202        .expect_err("invalid regex");
3203        assert!(err
3204            .to_string()
3205            .contains("invalid RegularExpressions pattern"));
3206
3207        let err = run_tokenized(vec![
3208            Value::String("abc".to_string()),
3209            Value::String("RegularExpressions".to_string()),
3210            Value::String(".*".to_string()),
3211        ])
3212        .expect_err("empty regex");
3213        assert!(err.to_string().contains("can match empty text"));
3214
3215        let table = table_from_columns(
3216            vec!["Type".into()],
3217            vec![Value::StringArray(
3218                StringArray::new(vec!["custom".into()], vec![1, 1]).unwrap(),
3219            )],
3220        )
3221        .expect("bad custom table");
3222        let err = run_tokenized(vec![
3223            Value::String("abc".to_string()),
3224            Value::String("CustomTokens".to_string()),
3225            table,
3226        ])
3227        .expect_err("missing token variable");
3228        assert!(err.to_string().contains("must contain a Token variable"));
3229    }
3230
3231    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3232    #[test]
3233    fn bag_of_words_counts_tokenized_documents() {
3234        let input = StringArray::new(
3235            vec![
3236                "an example of a short sentence".to_string(),
3237                "a second short sentence".to_string(),
3238            ],
3239            vec![2, 1],
3240        )
3241        .unwrap();
3242        let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3243        let bag = object(run_bag(vec![docs]).expect("bag"));
3244        assert_eq!(bag.class_name, BAG_OF_WORDS_CLASS);
3245        assert_eq!(
3246            string_array_property(&bag, "Vocabulary"),
3247            vec!["an", "example", "of", "a", "short", "sentence", "second"]
3248        );
3249        let Some(Value::Tensor(counts)) = bag.properties.get("Counts") else {
3250            panic!("expected Counts");
3251        };
3252        assert_eq!(counts.shape, vec![2, 7]);
3253        assert_eq!(counts.data[0], 1.0);
3254        assert_eq!(counts.data[1], 0.0);
3255        assert_eq!(counts.data[3 * 2], 1.0);
3256        assert_eq!(counts.data[3 * 2 + 1], 1.0);
3257    }
3258
3259    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3260    #[test]
3261    fn bag_of_words_accepts_unique_words_and_counts() {
3262        let words = StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap();
3263        let counts = Tensor::new(vec![2.0, 0.0, 1.0, 3.0], vec![2, 2]).unwrap();
3264        let bag =
3265            object(run_bag(vec![Value::StringArray(words), Value::Tensor(counts)]).expect("bag"));
3266        assert_eq!(bag.properties.get("NumDocuments"), Some(&Value::Num(2.0)));
3267        assert_eq!(bag.properties.get("NumWords"), Some(&Value::Num(2.0)));
3268    }
3269
3270    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3271    #[test]
3272    fn bag_of_words_rejects_duplicate_unique_words() {
3273        let words = StringArray::new(vec!["alpha".into(), "alpha".into()], vec![1, 2]).unwrap();
3274        let counts = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
3275        let err = run_bag(vec![Value::StringArray(words), Value::Tensor(counts)])
3276            .expect_err("expected duplicate rejection");
3277        assert!(err.to_string().contains("duplicate"));
3278    }
3279
3280    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3281    #[test]
3282    fn bag_of_words_drops_missing_unique_word_and_count_column() {
3283        let words = StringArray::new(
3284            vec!["alpha".into(), "<missing>".into(), "beta".into()],
3285            vec![1, 3],
3286        )
3287        .unwrap();
3288        let counts = Tensor::new(vec![1.0, 0.0, 9.0, 9.0, 2.0, 3.0], vec![2, 3]).unwrap();
3289        let bag =
3290            object(run_bag(vec![Value::StringArray(words), Value::Tensor(counts)]).expect("bag"));
3291        assert_eq!(
3292            string_array_property(&bag, "Vocabulary"),
3293            vec!["alpha", "beta"]
3294        );
3295        let counts = tensor_property(&bag, "Counts");
3296        assert_eq!(counts.shape, vec![2, 2]);
3297        assert_eq!(counts.data, vec![1.0, 0.0, 2.0, 3.0]);
3298    }
3299
3300    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3301    #[test]
3302    fn bag_of_words_checks_dense_count_size_before_allocation() {
3303        let err = checked_count_len(usize::MAX, 2, "bagOfWords")
3304            .expect_err("expected overflow rejection");
3305        assert!(err.to_string().contains("overflow"));
3306
3307        let err = checked_count_len(MAX_DENSE_BAG_COUNT_CELLS + 1, 1, "bagOfWords")
3308            .expect_err("expected dense size rejection");
3309        assert!(err.to_string().contains("sparse bag storage"));
3310    }
3311
3312    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3313    #[test]
3314    fn remove_short_words_filters_documents_and_bag() {
3315        let docs = run_tokenized(vec![Value::String(
3316            "an example of a short sentence".to_string(),
3317        )])
3318        .expect("tokenized");
3319        let filtered_docs =
3320            object(run_remove_short(docs.clone(), Value::Num(2.0)).expect("remove docs"));
3321        assert_eq!(
3322            string_array_property(&filtered_docs, "Vocabulary"),
3323            vec!["example", "short", "sentence"]
3324        );
3325
3326        let bag = run_bag(vec![docs]).expect("bag");
3327        let filtered_bag = object(run_remove_short(bag, Value::Num(2.0)).expect("remove bag"));
3328        assert_eq!(
3329            string_array_property(&filtered_bag, "Vocabulary"),
3330            vec!["example", "short", "sentence"]
3331        );
3332        let Some(Value::Tensor(counts)) = filtered_bag.properties.get("Counts") else {
3333            panic!("expected Counts");
3334        };
3335        assert_eq!(counts.shape, vec![1, 3]);
3336        assert_eq!(counts.data, vec![1.0, 1.0, 1.0]);
3337    }
3338
3339    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3340    #[test]
3341    fn remove_long_words_filters_documents_and_bag() {
3342        let input = StringArray::new(
3343            vec![
3344                "An example of a short sentence".to_string(),
3345                "A second compact note".to_string(),
3346            ],
3347            vec![2, 1],
3348        )
3349        .unwrap();
3350        let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3351        let filtered_docs =
3352            object(run_remove_long(docs.clone(), Value::Num(7.0)).expect("remove docs"));
3353        assert_eq!(
3354            documents_property(&filtered_docs),
3355            vec![vec!["An", "of", "a", "short"], vec!["A", "second", "note"]]
3356        );
3357        assert_eq!(
3358            string_array_property(&filtered_docs, "Vocabulary"),
3359            vec!["An", "of", "a", "short", "A", "second", "note"]
3360        );
3361
3362        let bag = run_bag(vec![docs]).expect("bag");
3363        let filtered_bag = object(run_remove_long(bag, Value::Num(7.0)).expect("remove bag"));
3364        assert_eq!(
3365            string_array_property(&filtered_bag, "Vocabulary"),
3366            vec!["An", "of", "a", "short", "A", "second", "note"]
3367        );
3368        let counts = tensor_property(&filtered_bag, "Counts");
3369        assert_eq!(counts.shape, vec![2, 7]);
3370        assert_eq!(
3371            counts.data,
3372            vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
3373        );
3374    }
3375
3376    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3377    #[test]
3378    fn remove_words_filters_by_word_list_indices_and_ignore_case() {
3379        let input = StringArray::new(
3380            vec![
3381                "Short second sentence".to_string(),
3382                "short example sentence".to_string(),
3383            ],
3384            vec![2, 1],
3385        )
3386        .unwrap();
3387        let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3388        let words = StringArray::new(vec!["short".into(), "example".into()], vec![1, 2]).unwrap();
3389        let filtered = object(
3390            run_remove_words(vec![
3391                docs.clone(),
3392                Value::StringArray(words),
3393                Value::String("IgnoreCase".to_string()),
3394                Value::Bool(true),
3395            ])
3396            .expect("remove words"),
3397        );
3398        assert_eq!(
3399            documents_property(&filtered),
3400            vec![vec!["second", "sentence"], vec!["sentence"]]
3401        );
3402
3403        let filtered_by_index = object(
3404            run_remove_words(vec![
3405                docs.clone(),
3406                Value::Tensor(Tensor::new(vec![1.0, 3.0], vec![1, 2]).unwrap()),
3407            ])
3408            .expect("remove indexed words"),
3409        );
3410        assert_eq!(
3411            documents_property(&filtered_by_index),
3412            vec![vec!["second"], vec!["short", "example"]]
3413        );
3414
3415        let bag = run_bag(vec![docs]).expect("bag");
3416        let mask = LogicalArray::new(vec![0, 1, 0, 1, 0], vec![1, 5]).unwrap();
3417        let filtered_bag =
3418            object(run_remove_words(vec![bag, Value::LogicalArray(mask)]).expect("remove mask"));
3419        assert_eq!(
3420            string_array_property(&filtered_bag, "Vocabulary"),
3421            vec!["Short", "sentence", "example"]
3422        );
3423        let counts = tensor_property(&filtered_bag, "Counts");
3424        assert_eq!(counts.shape, vec![2, 3]);
3425        assert_eq!(counts.data, vec![1.0, 0.0, 1.0, 1.0, 0.0, 1.0]);
3426    }
3427
3428    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3429    #[test]
3430    fn remove_words_rejects_bad_indices_and_mask_lengths() {
3431        let docs = run_tokenized(vec![Value::String("alpha beta".to_string())]).expect("tokenized");
3432        let err = run_remove_words(vec![docs.clone(), Value::Num(0.0)])
3433            .expect_err("expected bad numeric index");
3434        assert!(err.to_string().contains("positive integers"));
3435
3436        let mask = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
3437        let err = run_remove_words(vec![docs, Value::LogicalArray(mask)])
3438            .expect_err("expected mask length mismatch");
3439        assert!(err.to_string().contains("logical index length"));
3440    }
3441
3442    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3443    #[test]
3444    fn remove_stop_words_filters_tokenized_documents_with_case_option() {
3445        let input = StringArray::new(
3446            vec![
3447                "an example of a short sentence".to_string(),
3448                "The second short sentence".to_string(),
3449            ],
3450            vec![2, 1],
3451        )
3452        .unwrap();
3453        let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3454        let filtered = object(run_remove_stop(vec![docs]).expect("remove stop words"));
3455        assert_eq!(
3456            documents_property(&filtered),
3457            vec![
3458                vec!["example", "short", "sentence"],
3459                vec!["second", "short", "sentence"],
3460            ]
3461        );
3462
3463        let words = StringArray::new(
3464            vec!["The".to_string(), "the".to_string(), "word".to_string()],
3465            vec![1, 3],
3466        )
3467        .unwrap();
3468        let docs = run_tokenized(vec![
3469            Value::StringArray(words),
3470            Value::String("TokenizeMethod".to_string()),
3471            Value::String("none".to_string()),
3472        ])
3473        .expect("tokenized");
3474        let filtered = object(
3475            run_remove_stop(vec![
3476                docs,
3477                Value::String("IgnoreCase".to_string()),
3478                Value::Bool(false),
3479            ])
3480            .expect("case-sensitive remove stop words"),
3481        );
3482        assert_eq!(documents_property(&filtered), vec![vec!["The", "word"]]);
3483    }
3484
3485    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3486    #[test]
3487    fn erase_punctuation_filters_document_tokens_by_token_type() {
3488        let docs = object(
3489            run_tokenized(vec![Value::String(
3490                "An example: email help@example.com, visit https://example.com.".to_string(),
3491            )])
3492            .expect("tokenized"),
3493        );
3494        let filtered =
3495            object(erase_punctuation_tokenized_document(docs, vec![]).expect("erase punctuation"));
3496        assert_eq!(
3497            documents_property(&filtered),
3498            vec![vec![
3499                "An",
3500                "example",
3501                "email",
3502                "help@example.com",
3503                "visit",
3504                "https://example.com"
3505            ]]
3506        );
3507
3508        let words = StringArray::new(
3509            vec![
3510                "it's".to_string(),
3511                "alpha-beta".to_string(),
3512                "help@example.com".to_string(),
3513                "https://example.com".to_string(),
3514            ],
3515            vec![1, 4],
3516        )
3517        .unwrap();
3518        let docs = object(
3519            run_tokenized(vec![
3520                Value::StringArray(words),
3521                Value::String("TokenizeMethod".to_string()),
3522                Value::String("none".to_string()),
3523            ])
3524            .expect("tokenized"),
3525        );
3526        let filtered = object(
3527            erase_punctuation_tokenized_document(
3528                docs,
3529                vec![
3530                    Value::String("TokenTypes".to_string()),
3531                    Value::String("letters".to_string()),
3532                ],
3533            )
3534            .expect("erase letters punctuation"),
3535        );
3536        assert_eq!(
3537            documents_property(&filtered),
3538            vec![vec![
3539                "its",
3540                "alphabeta",
3541                "help@example.com",
3542                "https://example.com"
3543            ]]
3544        );
3545    }
3546
3547    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3548    #[test]
3549    fn rejects_unimplemented_japanese_tokenization() {
3550        let err = run_tokenized(vec![
3551            Value::String("東京に行きます".to_string()),
3552            Value::String("Language".to_string()),
3553            Value::String("ja".to_string()),
3554        ])
3555        .expect_err("expected unsupported ja");
3556        assert!(err.to_string().contains("MeCab"));
3557    }
3558
3559    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3560    #[test]
3561    fn tokenized_document_rejects_invalid_detect_patterns() {
3562        let err = run_tokenized(vec![
3563            Value::String("email me at a@example.com".to_string()),
3564            Value::String("DetectPatterns".to_string()),
3565            Value::StringArray(
3566                StringArray::new(vec!["all".into(), "web-address".into()], vec![1, 2]).unwrap(),
3567            ),
3568        ])
3569        .expect_err("expected mixed all rejection");
3570        assert!(err.to_string().contains("specified alone"));
3571
3572        let err = run_tokenized(vec![
3573            Value::String("email me at a@example.com".to_string()),
3574            Value::String("DetectPatterns".to_string()),
3575            Value::String("url".to_string()),
3576        ])
3577        .expect_err("expected unsupported pattern");
3578        assert!(err.to_string().contains("unsupported DetectPatterns"));
3579    }
3580
3581    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3582    #[test]
3583    fn tokenized_document_rejects_invalid_top_level_domains() {
3584        let err = run_tokenized(vec![
3585            Value::String("visit example.com".to_string()),
3586            Value::String("TopLevelDomains".to_string()),
3587            Value::String("co.uk".to_string()),
3588        ])
3589        .expect_err("expected invalid tld");
3590        assert!(err.to_string().contains("TopLevelDomains"));
3591    }
3592
3593    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3594    #[test]
3595    fn remove_short_words_requires_positive_integer_length() {
3596        let docs =
3597            run_tokenized(vec![Value::String("a short document".to_string())]).expect("tokenized");
3598        let zero =
3599            run_remove_short(docs.clone(), Value::Num(0.0)).expect_err("expected zero rejection");
3600        assert!(zero.to_string().contains("positive integer"));
3601
3602        let fractional =
3603            run_remove_short(docs, Value::Num(1.5)).expect_err("expected fractional rejection");
3604        assert!(fractional.to_string().contains("positive integer"));
3605    }
3606}