Skip to content

[Enhancement] Regex toggle for tag (and other string fields) on filter rules #240

Description

@salmon-21

Use case

When using LogFox with an app that derives the log tag from the calling class (Timber's DebugTree, SLF4J-android, etc.), a single logical class produces multiple distinct tags depending on whether the log call lives in a nested function or lambda.

Example from a real project: SyncRepository produces

SyncRepository
SyncRepository$fullSync
SyncRepository$deltaSync

To watch the whole class today I need three separate filter rows, and the list grows whenever a new helper function appears.

Current behavior

UserFilter.tag is matched by exact string equality:

https://github.com/F0x1d/LogFox/blob/master/feature/filters/api/src/main/kotlin/com/f0x1d/logfox/feature/filters/api/model/LogLinesExt.kt#L52-L60

private fun String?.equalsOrTrueIfNull(other: String) =
    if (this == null) true else other == this

This means "Sync" never matches "SyncRepository", and "SyncRepository" never matches "SyncRepository$fullSync".

Proposal

Borrow the convention used by VSCode, JetBrains IDEs, and most desktop find/replace UIs: a single Regex toggle per filter rule. Behavior:

  • Regex off (default for new filters): the field is treated as a literal substring (contains), not exact equality. This matches what users expect from a text search box.
  • Regex on: the field is compiled as a Regex and matched against the value.

Existing user impact

This is intentionally a zero-regression migration — every existing filter keeps matching exactly the same set of log lines after the upgrade. The new behavior is only visible to filters created or edited after the change. Details in the Migration section below.

API sketch

data class UserFilter(
    ...
    val tag: String? = null,
    val tagIsRegex: Boolean = false,
    val content: String? = null,
    val contentIsRegex: Boolean = false,
    ...
) {
    // Compiled once per filter instance; null when tag is null, blank, or
    // not in regex mode, OR when the pattern is invalid (see below).
    @Transient
    val tagRegex: Regex? by lazy {
        if (tagIsRegex && !tag.isNullOrEmpty()) {
            runCatching { tag.toRegex() }.getOrNull()
        } else null
    }
}

private fun UserFilter.tagSuits(other: String) = when {
    tag == null -> true
    tagIsRegex -> tagRegex?.containsMatchIn(other) ?: false  // bad regex => no match
    else -> other.contains(tag)
}

A few things worth calling out:

  • Compile once, not per log line. Log lines arrive in the thousands; compiling tag.toRegex() on every match would burn GC. The Regex instance is cached on the UserFilter (or in a tiny LRU keyed by filter id, depending on what fits the existing architecture better).
  • Invalid regex handling. tag.toRegex() can throw PatternSyntaxException. The edit screen should validate the pattern as the user types and refuse to save with a clear error; even so, the matching path wraps the compile in runCatching so a malformed pattern that somehow survives just makes the filter match nothing instead of crashing the log pipeline.
  • The toggle UI can be a small [.*] button next to the Tag field, similar to the existing search bar.

Migration

Switching the default from exact-equality to contains is a behavior change, so existing filters need to be migrated to preserve their meaning. Two options:

  1. One-shot DB migration to regex mode with escaped patterns. For every existing non-null tag, set tag = "^" + Regex.escape(tag) + "$" and tagIsRegex = true. This reproduces the current exact-equality semantics exactly, including for tags that contain regex metacharacters like $, ., (, etc. (the Regex.escape step is critical — a tag like SyncRepository$fullSync cannot just be wrapped with ^...$ because the $ would be interpreted as an end-of-line anchor and the filter would silently break).

  2. Keep an internal EXACT_LEGACY mode for pre-migration filters, where matching still uses ==. New filters use the new model. Slightly more code paths to maintain but no rewriting of existing rows.

I'd lean toward option 1 — one DB migration, one mental model going forward. Either way, the user-visible behavior of existing filters does not change.

Why

  • One filter row instead of N for SyncRepository* style auto-derived tags
  • Matches the search-toggle UX users already know from IDEs and editors
  • Same toggle naturally extends to content (which already contains-matches) and package if you want to keep them symmetric, but those can come in follow-up PRs

Happy to send a PR if this direction is acceptable.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions