Skip to content

Commit 30d25b4

Browse files
authored
Merge pull request #3 from aehrc/fix/replace-inactive-chained-regex-issue-2
fix: position-based Replace Inactive to avoid chained-regex corruption
2 parents 4fc93a1 + f3d2cfc commit 30d25b4

4 files changed

Lines changed: 171 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3333
- **Anonymous Install Metrics**: Random UUID in User-Agent for install counting — no personal data collected.
3434
- **Welcome Screen**: First-launch onboarding with mailing list and GitHub links.
3535

36+
### Fixed
37+
- **Replace Inactive**: Fixed chained-regex replacement that could corrupt output when one inactive concept's replacement target SCTID matched another inactive concept's ID in the same selection. Replacements are now located by position against the original text and applied in reverse order, mirroring Replace Selection (issue #2). The completion message now reports the number of concepts actually replaced (rather than the number attempted) and logs a warning if any concept with a replacement could not be located in the text.
38+
3639
### Security
3740
- XSS prevention in WebView rendering
3841
- Input size and depth limits on all parsers

‎Codeagogo/AppDelegate.swift‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,19 +1122,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
11221122
return
11231123
}
11241124

1125-
// 6. Replace in text — use regex to match concept ID + optional display term
1126-
var result = text
1127-
for (conceptId, replacement) in replacementsByCode {
1128-
let pattern = "(?<![0-9])" + NSRegularExpression.escapedPattern(for: conceptId) + "(?![0-9])" + "(\\s*\\|[^|]*\\|)?"
1129-
if let regex = try? NSRegularExpression(pattern: pattern) {
1130-
let range = NSRange(result.startIndex..., in: result)
1131-
result = regex.stringByReplacingMatches(
1132-
in: result,
1133-
options: [],
1134-
range: range,
1135-
withTemplate: NSRegularExpression.escapedTemplate(for: replacement)
1136-
)
1137-
}
1125+
// 6. Replace in text using position-based substitution against the
1126+
// original text. Matching the concept ID (plus its optional display
1127+
// term) by position and applying replacements in reverse order makes
1128+
// each replacement immune to text inserted by other iterations —
1129+
// avoiding corruption when one inactive concept's replacement target
1130+
// equals another inactive concept's ID (see issue #2).
1131+
let replacement = model.replacingInactiveConcepts(in: text, with: replacementsByCode)
1132+
let result = replacement.text
1133+
1134+
// Reconcile intent against reality: a concept may have a replacement
1135+
// but not be located in the text by the extractor (e.g. input over the
1136+
// extraction size limit). Don't report those as replaced — log instead.
1137+
let missed = Set(replacementsByCode.keys).subtracting(replacement.replacedConceptIds)
1138+
if !missed.isEmpty {
1139+
AppLog.warning(AppLog.ui, "Replace inactive: \(missed.count) concept(s) had a replacement but could not be located in the text: \(missed.sorted())")
11381140
}
11391141

11401142
// 7. Put result on clipboard and paste
@@ -1150,8 +1152,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
11501152

11511153
progressHUD.hide()
11521154

1153-
let count = replacementsByCode.count
1155+
let count = replacement.replacedConceptIds.count
11541156
var message = "Replaced \(count) inactive concept\(count == 1 ? "" : "s")"
1157+
if !missed.isEmpty {
1158+
message += " (\(missed.count) could not be located)"
1159+
}
11551160
if !noReplacementIds.isEmpty {
11561161
message += " (\(noReplacementIds.count) had no replacement)"
11571162
}

‎Codeagogo/LookupViewModel.swift‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,54 @@ final class LookupViewModel: ObservableObject {
419419
return ConceptMatch(conceptId: conceptId, range: fullRange, existingTerm: existingTerm, isSCTID: isSCTID)
420420
}
421421

422+
/// The outcome of an inactive-concept replacement pass.
423+
struct InactiveReplacementResult {
424+
/// The text after substitution.
425+
let text: String
426+
/// The concept IDs that were actually located in the text and replaced.
427+
///
428+
/// This may be a strict subset of `replacementsByCode.keys`: a code that
429+
/// was supplied a replacement but is not surfaced in `text` by
430+
/// ``extractAllConceptIds(from:)`` (e.g. input over the extraction size
431+
/// limit, or a token the extractor's boundaries reject) is not replaced
432+
/// and does not appear here. Callers should reconcile against the
433+
/// intended keys rather than assume every replacement was applied.
434+
let replacedConceptIds: Set<String>
435+
}
436+
437+
/// Replaces inactive concept IDs in `text` with their target replacements.
438+
///
439+
/// Every concept ID that ``extractAllConceptIds(from:)`` locates in `text`
440+
/// and that is present as a key in `replacementsByCode` is replaced — along
441+
/// with its optional pipe-delimited term — by the mapped replacement string.
442+
/// A key with no corresponding match in `text` is left untouched and is
443+
/// reported via `InactiveReplacementResult.replacedConceptIds` (by omission).
444+
///
445+
/// Replacements are located against the **original** `text` and applied in
446+
/// reverse positional order using `replaceSubrange`. Because positions are
447+
/// resolved before any mutation and applied back-to-front, text inserted by
448+
/// one replacement can never be re-matched by another. This avoids the
449+
/// corruption that a chained regex over an accumulating result produces when
450+
/// one inactive concept's replacement target equals another inactive
451+
/// concept's ID (see issue #2).
452+
///
453+
/// - Parameters:
454+
/// - text: The original selection text.
455+
/// - replacementsByCode: Map of inactive concept ID → replacement text
456+
/// (a code with optional term, or an `(A OR B)` group).
457+
/// - Returns: The substituted text and the set of concept IDs actually replaced.
458+
func replacingInactiveConcepts(in text: String, with replacementsByCode: [String: String]) -> InactiveReplacementResult {
459+
let matches = extractAllConceptIds(from: text)
460+
var result = text
461+
var replaced: Set<String> = []
462+
for match in matches.reversed() {
463+
guard let replacement = replacementsByCode[match.conceptId] else { continue }
464+
result.replaceSubrange(match.range, with: replacement)
465+
replaced.insert(match.conceptId)
466+
}
467+
return InactiveReplacementResult(text: result, replacedConceptIds: replaced)
468+
}
469+
422470
/// Copies a string to the system pasteboard.
423471
///
424472
/// - Parameter s: The string to copy. If `nil` or empty, no action is taken.

‎CodeagogoTests/LookupViewModelTests.swift‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,4 +413,105 @@ final class LookupViewModelTests: XCTestCase {
413413

414414
XCTAssertEqual(result, "73211009")
415415
}
416+
417+
// MARK: - Replace Inactive Concepts Tests
418+
419+
/// Regression test for issue #2: two inactive concepts whose replacement
420+
/// targets are each other's IDs (a 2-cycle swap). The old chained-regex
421+
/// approach corrupted this in **both** dictionary iteration orders — whichever
422+
/// code was processed first had its just-inserted target re-matched and
423+
/// clobbered by the second iteration, so no ordering produced the correct
424+
/// independent mapping. Position-based substitution resolves all positions
425+
/// against the original text up front, so each original code maps to its own
426+
/// target regardless of order. (Numbers are synthetic, not real SCTIDs.)
427+
///
428+
/// The swap is used deliberately rather than a one-directional collision
429+
/// (A→B, B→C): the latter's expected output coincidentally matches the buggy
430+
/// output in one of the two iteration orders, so it would not reliably fail
431+
/// against the old code. The swap fails the old code in every order.
432+
func testReplacingInactiveConcepts_swapCycle_doesNotChainInEitherOrder() async {
433+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
434+
let text = "11111111 and 22222222"
435+
let replacements = [
436+
"11111111": "22222222 |Concept B|",
437+
"22222222": "11111111 |Concept A|",
438+
]
439+
440+
let result = viewModel.replacingInactiveConcepts(in: text, with: replacements)
441+
442+
XCTAssertEqual(
443+
result.text,
444+
"22222222 |Concept B| and 11111111 |Concept A|",
445+
"Each original code must map to its own target, not chain through inserted text"
446+
)
447+
XCTAssertEqual(result.replacedConceptIds, ["11111111", "22222222"])
448+
}
449+
450+
/// Verifies an inactive concept's existing pipe-delimited term is consumed
451+
/// and replaced along with the code.
452+
func testReplacingInactiveConcepts_replacesExistingTerm() async {
453+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
454+
let text = "73211009 | Old display |"
455+
let replacements = ["73211009": "385804009 |New display|"]
456+
457+
let result = viewModel.replacingInactiveConcepts(in: text, with: replacements)
458+
459+
XCTAssertEqual(result.text, "385804009 |New display|")
460+
}
461+
462+
/// Verifies concepts not present in the replacement map are left untouched.
463+
func testReplacingInactiveConcepts_leavesUnmappedCodesUntouched() async {
464+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
465+
let text = "73211009 and 22222222"
466+
let replacements = ["22222222": "99999999 |Concept C|"]
467+
468+
let result = viewModel.replacingInactiveConcepts(in: text, with: replacements)
469+
470+
XCTAssertEqual(result.text, "73211009 and 99999999 |Concept C|")
471+
XCTAssertEqual(result.replacedConceptIds, ["22222222"])
472+
}
473+
474+
/// Verifies multiple occurrences of the same inactive code are all replaced.
475+
func testReplacingInactiveConcepts_replacesAllOccurrences() async {
476+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
477+
let text = "11111111 OR 11111111"
478+
let replacements = ["11111111": "22222222 |Concept B|"]
479+
480+
let result = viewModel.replacingInactiveConcepts(in: text, with: replacements)
481+
482+
XCTAssertEqual(result.text, "22222222 |Concept B| OR 22222222 |Concept B|")
483+
}
484+
485+
/// Verifies empty text and text with no extractable codes are returned
486+
/// unchanged with nothing reported as replaced.
487+
func testReplacingInactiveConcepts_emptyOrNoMatch_returnsUnchanged() async {
488+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
489+
let replacements = ["11111111": "22222222 |Concept B|"]
490+
491+
let empty = viewModel.replacingInactiveConcepts(in: "", with: replacements)
492+
XCTAssertEqual(empty.text, "")
493+
XCTAssertTrue(empty.replacedConceptIds.isEmpty)
494+
495+
let noCodes = viewModel.replacingInactiveConcepts(in: "<< no codes here", with: replacements)
496+
XCTAssertEqual(noCodes.text, "<< no codes here")
497+
XCTAssertTrue(noCodes.replacedConceptIds.isEmpty)
498+
}
499+
500+
/// Verifies that a code supplied a replacement but absent from the text is
501+
/// NOT reported as replaced — so callers can detect the mismatch rather than
502+
/// over-reporting success.
503+
func testReplacingInactiveConcepts_reportsOnlyCodesActuallyReplaced() async {
504+
let viewModel = LookupViewModel(selectionReader: MockSelectionReader(), client: MockLookupClient())
505+
let text = "73211009 only"
506+
let replacements = [
507+
"73211009": "385804009 |Present|",
508+
"22222222": "99999999 |Absent|",
509+
]
510+
511+
let result = viewModel.replacingInactiveConcepts(in: text, with: replacements)
512+
513+
XCTAssertEqual(result.text, "385804009 |Present| only")
514+
XCTAssertEqual(result.replacedConceptIds, ["73211009"],
515+
"A code absent from the text must not be reported as replaced")
516+
}
416517
}

0 commit comments

Comments
 (0)