@@ -3249,6 +3249,27 @@ function sanitizeHyphenatedTerm(term: string): string {
32493249 return term . split ( '-' ) . map ( t => sanitizeFTS5Term ( t ) ) . filter ( t => t ) . join ( ' ' ) ;
32503250}
32513251
3252+ /**
3253+ * Check if a token is a dotted version/version-like string (e.g., 2026.4.10, 3.14.0).
3254+ * Returns true if splitting on dots yields at least 2 non-empty parts consisting of
3255+ * word/digit characters only. This avoids incorrectly splitting tokens with leading/
3256+ * trailing dots. Version strings like "2026.4.10" split into ["2026","4","10"] (3 parts).
3257+ */
3258+ function isDottedToken ( token : string ) : boolean {
3259+ const parts = token . split ( '.' ) ;
3260+ return parts . length >= 2 && parts . every ( p => p . length > 0 && / ^ [ \p{ L} \p{ N} _ ] + $ / u. test ( p ) ) ;
3261+ }
3262+
3263+ /**
3264+ * Sanitize a dotted term into individual FTS5 tokens joined with AND.
3265+ * e.g. "2026.4.10" → '"2026"* AND "4"* AND "10"*'
3266+ * The AND ensures all parts must appear, matching how the porter tokenizer
3267+ * indexes dotted strings.
3268+ */
3269+ function sanitizeDottedTerm ( term : string ) : string {
3270+ return term . split ( '.' ) . map ( t => sanitizeFTS5Term ( t ) ) . filter ( t => t ) . map ( t => `"${ t } "*` ) . join ( ' AND ' ) ;
3271+ }
3272+
32523273/**
32533274 * Parse lex query syntax into FTS5 query.
32543275 *
@@ -3325,6 +3346,24 @@ function buildFTS5Query(query: string): string | null {
33253346 positive . push ( ftsPhrase ) ;
33263347 }
33273348 }
3349+ } else if ( isDottedToken ( term ) ) {
3350+ // Handle dotted version strings: 2026.4.10, 3.14.0, v1.2.3
3351+ // The porter tokenizer splits on dots, so the index has individual tokens.
3352+ // We AND all parts together so the query matches documents containing all parts.
3353+ const sanitized = sanitizeDottedTerm ( term ) ;
3354+ if ( sanitized ) {
3355+ // sanitizeDottedTerm already wraps each part in quotes with prefix match
3356+ if ( negated ) {
3357+ // Wrap multi-token AND expression in parens for NOT negation
3358+ negative . push ( `(${ sanitized } )` ) ;
3359+ } else {
3360+ // Flatten individual AND'd terms into the positive list so they combine
3361+ // correctly with other terms (avoids double-wrapping in outer AND).
3362+ for ( const part of sanitized . split ( ' AND ' ) ) {
3363+ positive . push ( part . trim ( ) ) ;
3364+ }
3365+ }
3366+ }
33283367 } else if ( containsCjk ( term ) ) {
33293368 const sanitized = sanitizeFTS5Phrase ( term ) ;
33303369 if ( sanitized ) {
0 commit comments