Changelog
Source:NEWS.md
edstr (development version)
New features
-
edstr_config()gainsedstr_connect_dir, the connection file pathedstr_import()reads. It was the one package option reachable only through..., so it never appeared in?edstr_configand a mistyped value reachedfs::file_exists()as a bare R error instead of a cli abort. Like the other optional settings, leaving it out resets it.
Bug fixes
edstr_extract()highlights a word carrying a ligature instead of leaving it bare. A token is read off the folded text, soœsophagearrived asoesophageand expanded to two positions against the source’s one, which no single-character class could match back:æin theaclass andœin theeclass were unreachable by construction.data$extractwas unaffected, re2 matching the fold and slicing the original back through the offset maps, so the loss fell on the ICU consumers of the same pattern alone: the highlight indata$note, the XLSX and the gt, and the fallback of the source pass on a document whose transliteration is context dependent. Two rulesoeandaenow run before the vowel rules and carry the ligature as an alternative to the letter pair, the accent classes the pair still has to reach included.edstr_extract()rejects aregex_replacerule it cannot apply instead of returning an empty extraction. An unnamed element carried an empty pattern into the expansion, which stringi refuses and returnsNAfor, so the alternation read(?i)\b(NA)\b, confirmed nothing anywhere and sent every match tomismatchedbehind a singlemutate()warning. A rule whose pattern reaches any character (.,\W, a negated class) matched the private-use sentinels the expansion parks and deleted the replacement they stood for, with the same silent outcome. Both abort now, the second naming the rule whose expansion was destroyed.edstr_extract()carries aregex_replacevalue to the source pattern as the regex fragment it is written as. The value was applied as a replacement, where\is an escape, soc(y = "[y\\s]")reached the pattern as[ys]and matched the lettersin place of a whitespace, silently. It is escaped out of the replacement side now, and the built-in whitespace rule drops the doubling it needed to survive it.edstr_extract()honours aregex_replacerule instead of letting it destroy the source-matching pattern. The built-in accent rules and the user ones were applied in sequence over each other’s output, so a rule whose pattern matched a character an earlier replacement had emitted rewrote that replacement. A rule on any ofa,e,i,o,uor whitespace, or on any ofs,b,r,[that the built-in whitespace rule emits, turned the expanded pattern into a broken character class: no document confirmed a single match and the whole corpus landed inunmatched$no_concept, with no error. Each replacement is now parked behind a private-use sentinel until every rule has run, so no rule reads another’s output. A rule whose pattern contains a character a built-in covers therefore never fires rather than widening it, which is what it already did whenever it was not destructive:"oe"is inert,"y"is not. A backreference in a replacement is not supported.edstr_extract()marks and extracts the longest expression matching at a position instead of the most frequent one. Each concept’s alternation was ordered by decreasing frequency, and a trigram’s constituent bigram is always at least as frequent as the trigram itself, so the bigram won every position the two shared.data$extractdeliveredaccident vasculaire ; cérébralwhere one span would do, and the highlight indata$note, the XLSX and the gt closed afteraccident vasculaire, leavingcérébralbare. Branches are now ordered by decreasing token length, the only lever the two paths running on ICU have, and the re2 passes take its exactlongest_matchmode. Measured on a 7883-document corpus: 15 of the 1804 extracts change, 13 of them ending with fewer spans, and no document changes bucket.mismatchedfollows the same ordering, the confirmation failure moving from the trigram onto the bigram it displaced, 19 rows down to 18.edstr_extract()confirms an n-gram the alternation displaced instead of reporting it as a mismatch. One alternation per concept returns one match per position, so where a trigram and its constituent bigram both match, only the winner gets a row and the other surfaces inmismatcheddespite being present in the source. Each unconfirmed token is now re-tested on its own, in its own document, and the span it finds is added through the same offset machinery, so it carries the original accents and case rather than the folded token. The work is proportional to the failures rather than to the corpus: on a 7883-document corpus it costs about 0.27 s against a 6.4 s stage and takesmismatchedfrom the 18 rows the reordering above leaves to 4. The four that remain have no confirmable span at all, one of them an expression straddling a paragraph boundary.data$extract,data$note,unmatchedandn_no_conceptare untouched; the pass only ever adds todata$regex$matchand the counts derived from it, and never records a span more often than the document actually carries it.edstr_extract()no longer surfaces a raw<br/>indata$extract. An expression the export split across a hard line break was captured with the tag inside its span, so the extract readremaniements <br/>hémorragiques. The separator that admits the tag always retains at least one of space, hyphen or apostrophe, so dropping the tag cannot glue the two words together. The document text indata$notekeeps it, being quoted as it stands.edstr_extract()returns the true surface form for a document whose folded length ties the original by coincidence. Matches are located on aLatin-ASCIIcopy of the source and their positions travel back to the original through a per-character offset map, which was built only for documents the fold changed the length of. A document carrying a combining mark takes a slower transliteration that can drop it onto its base, and a ligature expanding elsewhere in the same document restores the count: the length tied, no map was built, and the folded positions sliced the original directly.data$extract, the note and the highlighted XLSX and gt then quoted text shifted off the match, with no error.edstr_extract()no longer anchors^and$per line on the document where source confirmation falls back to a second engine. That fallback runs for any document whose offset map fails its consistency check, and it anchored per line where the primary pass anchors on the whole text. A concept pattern carrying^or$could therefore report more matches on the fallback path than on the normal one, which also moved themismatchedrows and the true-negative count derived from them. One narrower difference remains between the two engines, reachable only through aregex_replaceentry that injects an anchor:$matches before a single trailing newline on the fallback and at the very end of the text on the primary pass.edstr_extract()creates its output directory private to the user (0700) instead of leaving it at the ambient umask. The saved XLSX, RDS and note quote the source text verbatim andano_hash/ano_hidedo not cover it, so on a shared host a clinical extract was readable by anyone able to traverse the parent directory. An output directory that already exists keeps the permissions it was created with, so restrict earlier ones by hand.edstr_import()no longer echoes the database password when prompting for it. Outside RStudio the prompt fell back toreadline(), which printed every keystroke and left the credential in the terminal scrollback; it now reads throughaskpass::askpass()(newSuggests).edstr_view()enforces the<integer(1)>contract its documentation already stated forngrams. The value reached the regex quantifier unchecked, so0or a fractional value surfaced as a raw ICUU_REGEX_BAD_INTERVAL, and a length-2 vector recycled against a two-row frame, searching each row with a different window and reporting nothing.edstr_extract()enforces the<character(1)>contract its documentation already stated forexclus_manualandexclus_auto_escape. A length-2exclus_manualdied on'length = 2' in coercion to 'logical(1)'fourteen frames into the exclusions stage, and the same value inexclus_auto_escapedied on a vctrs recycling error one stage earlier. Worse than either, a value as long as the matched-row count recycled instead of failing, applying a different pattern to each row. Alternation goes inside the pattern,"a|b"rather thanc("a", "b").edstr_extract()counts a document whose every match fails source confirmation as a true negative. Such a document was dropped fromdata$extractby the join on the confirmed matches while still counting as matched for the unmatched split, so it reached neither the delivered frame nor anyunmatchedbucket andn_no_concept, the documented denominator, was short by that set.edstr_extract()accepts adatawhose identifier column is namedid. Inside the data-masked filters the column shadowed the argument holding its own name, so the subscript received a vector and the run aborted onno such index at level 1before producing anything.edstr_extract()returns its result invisibly, as documented. Neither the computed nor the cached path was invisible, so an unassigned call auto-printed the whole nested list on top of the summary it had just formatted. Assigning the result is unaffected.edstr_clean(),edstr_view()andedstr_extract()name the argument they are missing.replace,patternandconceptshad no default, so omitting one produced R’s bareargument "x" is missing, with no defaultinstead of the actionable abortedstr_import()already gave forquery. Forconceptsthe run got further still: the leaf walk skips an empty set, so the pipeline reached the matching stage and blamed “No matches found”.edstr_import()refuses an empty password instead of sending it.readline()answers itself with""when nobody is there to type, andSys.getenv()yields""for an unset variable, so a scripted run reached the server with an empty password and spent an authentication attempt against the account’s lockout counter instead of reporting a missing credential.passwordis now required outside an interactive session, and""is rejected in every session.edstr_extract()keeps the" ; "separator indata$note$extract. A semicolon strip left over from the CSV output deleted it, so the column ran its matches together on a double space whiledata$note$concept, in the same frame, kept the separator: splitting both on" ; "returned incomparable lengths. Storednoteoutput from an earlier run differs on this column.edstr_extract()gives every declared concept a column indata$extract. A concept no surviving match reached got no column at all instead of a column of0, so the delivered schema followed the corpus rather than the concept set, and a prevalence loop over the keys readsum(NULL), which is0, and looked correct.edstr_extract()counts a document whose every match is excluded as a true negative. Such a document was dropped fromdata$extractyet kept out of everyunmatchedbucket, so it was reported nowhere andn_no_concept, documented as exact, understated the denominator. It now lands inno_concept, the exclusions having ruled its matches false positives.edstr_extract()no longer searches documents that have no source text. ANAsource was formatted into the string"NA", which reached tokenisation as the tokennaand could be matched by a concept, so a document reported asunmatched$no_sourcecould also surface as a match.edstr_extract()no longer reports excluded tokens as source mismatches. Tokens dropped byexclus_manualor the automatic heuristics were compared against a source table built after those exclusions, so every one of them landed inmismatchedwith the warning “token matched, not confirmed in source”.edstr_extract()rejects adatacolumn whose name the output builds for itself. A column namedextractorid_groupwas overwritten without a word, and one named like a concept key was silently split into<key>.xand<key>.y;nandconceptaborted deep in the assembly with an opaque message once the whole pipeline had run.edstr_extract()reports a multi-pattern concept root the same way whatever the root count. The check ran after the concepts were parsed, and parsing survived that input on a single root only, through column recycling; two or more roots aborted earlier withTibble columns must have compatible sizes.edstr_extract()normalises sub-concept names given as a named character vector. Only list names were lowercased and stripped, solist(cancer = c("Sein Droit" = "sein"))reached thenoteoutput asclass='extract Sein Droit', which a browser reads as two separate classes.edstr_extract()appliesano_hashandano_hideto every output. The anonymised frame was built inside the text formatter and then reduced to the key and text columns, which dropped every column it had transformed before anything read it, while every output kept deriving from the untouched input:ano_hash = "ipp"left the column in clear indata$base,data$extract,data$note, the Excel sheets and thegttables, with the documentation promising the opposite. Anonymisation now runs once on the input frame, before extraction. Both arguments are matched case-insensitively (ano_hashwas case-sensitive, so a pattern could silently miss a capitalised column), the hash keeps 16 hexadecimal characters instead of 7 (28 bits collided with probability ~12% over 8000 rows), and a pattern matching no column, or matchingtext_input, now aborts instead of passing silently. Both also read their pattern in the same regex dialect:ano_hidewas applied throughgrep(), so a construct such as a lookahead passed validation and then failed insidedplyrwithinvalid regular expression.edstr_extract()rejects anano_hashorano_hidepattern that matches theidorgroupcolumn. Anonymising a key desynchronised the tokenised frame from the one carrying the results: pointingano_hideat the document id reported every document as a true negative, with no error and no warning.edstr_config()rejects an empty or missing value for the four string settings and foredstr_overwrite. Type and length were checked, emptiness was not, andstr_glue()rendersNAas the literal"NA":edstr_filename = NAwas accepted and prefixed every saved file withNA_, whileedstr_overwrite = NApassedis.logical()and surfaced much later asmissing value where TRUE/FALSE needed.The three anonymisation aborts name only the argument at fault. All of them titled on
ano_hashandano_hidetogether, so a run that passed one of the two was pointed at an argument it had never supplied; each pattern is now traced back to the argument it came from. Two agreement slips went with them,must each matchon a single argument andX and Y matchesin the plural.edstr_extract()no longer reports an apostrophe separator as a source mismatch. Source matching accepts an apostrophe wherever a token holds a space, but the mismatch check normalised only hyphens and<br/>, so a span such asl' aortewas confirmed in the source and then reported as unconfirmed anyway.edstr_extract()names the default conceptconceptson every path. An unnamed single concept produced the keyconcept, which collided with theconceptoutput column and aborted the pipeline withCan't select columns that don't exist; a collapsed flat set produced the literal key<concept>, which surfaced as a column name and as a malformed CSS class in thenoteoutput.edstr_extract()now pairs each n-gram size with its own tokenised text. Atokenvector that was not consecutive from1either matched against the wrong n-gram size without warning, as withtoken = c(2, 1), or failed withsubscript out of bounds, as withtoken = c(1, 3).edstr_extract()keeps invisible formatting characters in thenoteoutput. The highlighting restored its spans through a fixed-string search, which skips default-ignorable code points, so a zero-width no-break space (U+FEFF) sitting next to a highlighted match was consumed along with the placeholder and deleted from the text. Exports produced on Windows carry that character at the head of a document. Characters ICU does not skip, the soft hyphen among them, were never affected.edstr_extract()no longer highlights its own markup in thenoteoutput. A concept matching a word the markup itself uses, such asextract,span,class, or another concept’s name, produced nested and malformed spans.edstr_extract()now matches terms written with a ligature.cœur,œdème,œsophageand the like matched at the token level but could never be confirmed against the source, so they were reported asmismatchedand dropped from the extraction. Both the ligature and the two-letter spelling are now found, and each is returned as written in the source.edstr_extract(collapse = TRUE)now collapses a nested list of named character vectors instead of deparsing it. Aconceptslist such aslist(cancer = c(sein = "sein", poumon = "poumon"))used the R source of each vector as its pattern, so the concept matched nothing. Only a list of lists collapsed correctly.edstr_extract()now aborts with an actionable message when a concept root groups several sub-patterns as a named vector undercollapse = FALSE, instead of failing deep in tokenisation with a cryptic recycling error. Track sub-concepts separately with a nested list,list(cancer = list(sein = "sein", poumon = "poumon")), which the documented examples now use.edstr_extract(intersect = TRUE)now rejects a single root that carries several sub-concepts, instead of silently ignoring the intersection. The guard counts roots, not concept keys, so a nested single-root input no longer slips through.edstr_extract(collapse = TRUE)now collapses a set of single-pattern nested lists instead of deparsing it.list(cancer = list(sein = "sein"), diab = list(t2 = "diabet"))leaves every root at length one, which took the flat branch and used the R source of each inner list as its pattern. The regex stayed syntactically valid, so the run ended onNo matches foundwith nothing else to go on.A
.sqlfile passed toedstr_import(query = )keeps the comment markers that sit inside a string literal. Comment stripping ran on the raw text, soLIKE '%--%'was truncated toLIKE '%andnom != '---', the package’s own default hide pattern, tonom != '. Those break loudly on an unbalanced quote, but a block marker spanning two literals did not:SELECT '/*' AS a, 'x' AS b, '*/' AS c FROM twas sent asSELECT '' AS c FROM t, valid SQL over a different result set.An empty Excel sheet keeps a uniform header row.
wb_dims(select = "data")collapses onto the header on a zero-row frame, and the concept and text colouring was the one data-scoped style left ungated, so it repainted the header cells of the columns it targets. A colour naming a column absent from an empty sheet also aborted the whole workbook build withColumn exceeds valid range, the normalisation that filters such names running only when the frame carried rows.The
nrow counter ofdata$extractanddata$noteis an integer. It came fromrownames_to_column(), so it was text: the delivered Excel sheet and thegttable sorted it lexicographically, putting row10before row2.edstr_extract()reports a multi-pattern concept at any nesting depth. The guard inspected the top level only, so its own hint, wrapping sub-concepts inlist(), led straight to a shape it no longer covered:list(cancer = list(sein = c("sein", "mammaire")))reached theregex_dfbuild and died onTibble columns must have compatible sizes. The abort now names the flattened concept,cancer_sein.edstr_extract(ano_hash = )keeps missing values missing. EveryNAin a hashed column was hashed like any other value and came back as one shared 16-character pseudonym, so completeness reporting saw no missing value left and, worse, a join on the pseudonymised column matched all the identity-unknown rows with one another, merging distinct records into a single pseudo-patient.idandgroupare out of reach of the pattern, but any secondary identifier is not.edstr_extract(intersect = TRUE)now counts a document matching some roots but not all as a non-case, instead of dropping it from every output. Such a document was absent fromdata$extractand from all fourunmatchedbuckets at once, so the counts did not close andn_no_concept, which the documentation designates as the denominator, was understated: a prevalence over three documents where one matched both roots, one matched a single root and one matched neither came out at 1/2 instead of 1/3. It now lands inno_concept, the unit underintersect = TRUEbeing the compound concept.data$matchstays pre-intersect, soanti_join(data$match, data$extract, by = id)still recovers the set. Results stored from an earlierintersect = TRUErun are not comparable.edstr_extract()rejects two concepts that share a sub-name.list(cancer = c(sein = "mammaire"), benin = c(sein = "adenofibrome"))kept the roots apart in the dummy columns but labelled bothsein, and the sub-name is what groups the source passes, so the two concepts were matched as one and theconceptcolumn contradicted the dummy columns on the same row.
Performance
edstr_extract()is about 40% faster, with identical output. The highlight markup built for thenoteoutput now runs only over the documents a concept actually matched, and theLatin-ASCIItransliteration applied to the source is reproduced on a faster Unicode fold.The token-matching stage joins the occurrence table once instead of once per concept, about 4 times faster at every
ngram_maxvalue. The gain grows with the number of n-gram sizes searched:ngram_max = 3goes from about 21 seconds to 5 on the reference corpus.The automatic exclusion scan is skipped when no n-gram reaches
exclus_auto_token_min, which is what its default of10means in practice. The scan used to run in full and have its entire output discarded.The Excel export builds about 40% faster, with identical output. Cell borders were painted over the whole table, which openxlsx2 resolves in time that grows super-linearly with the range; the border style is now resolved on two prototype rows and broadcast, so the largest sheet no longer dominates the build.
edstr_extract()now matches the source text with there2engine instead of ICU, which runs the concept alternation in a single linear pass. Matches are located on aLatin-ASCIIcopy of the source, the same transliteration used for tokenisation, then sliced from the original so extracted text keeps its accents, ligatures and case unchanged.re2is a new hard dependency.
Documentation
The
edstr_extract()example runs. It was wrapped in\dontrun{}because it drew its input fromedstr_import(), so no line of the flagship function’s documentation was ever executed; it now builds an inline data frame undertempdir(), like theedstr_clean()example, and onlyedstr_import()still needs\dontrun{}.New
vignette("matching")explains the two matching stages, why concept patterns are written without accents, and how the accented source form is recovered.exclus_auto_token_minis now documented as what it is: a threshold measured in n-gram sizes, whose default of10sits above every realisticngram_maxvalue and so disables automatic exclusion entirely. Set it to0to submit every n-gram size to the scan.ngramsinedstr_view()is described consistently in the vignettes and the reference as the width of the regex window captured around a match, the matched word included.edstr_view()does not tokenise, so the earlier n-gram wording named the wrong mechanism and read as if all three words were captured after the match;ngrams = 3widens the pattern by up to two further words. The reference now points atngram_maxinedstr_extract()as the distinct argument it is easily confused with.collapse = TRUEis described by the predicate it actually applies. The reference and the three prose surfaces said one regex per root concept “when they are nested”, but nesting is not what decides it: a root holding several patterns is, so a nested list whose roots each hold a single pattern falls back to one regex namedconceptsfor the whole set and loses the root names.ano_hidedocuments its precedence overano_hash. A column matched by both is hashed first and then masked, so"---"is what reaches the outputs.The vignettes drop 21
#| error: truechunk options.vignettes/_quarto.ymlsetseval: falsefor the whole project, so no chunk inclean,explore,extractorimportever executed and none of those options could fire; the four rendered pages are byte-identical without them. The per-chunkeval: falseinconfigandedstrstays: those two are the vignettes that cannot evaluate at all, so the local option is what would keep them safe if the project default were ever flipped.Nine unused
man/figures/lifecycle-*.svgfiles leave the tarball. The README badge is served from shields.io andlifecycleis in neitherImportsnorSuggests, so nothing referenced them.
Breaking changes
edstr_extract(): thetokenargument is replaced byngram_max, a single whole number giving the largest n-gram size to tokenise. Every smaller size is searched too, songram_max = 3covers whattoken = c(1, 2, 3)covered. The sequence was always contiguous from 1 in real use, so the vector form only exposed the discontinuous and reordered inputs that produced the two bugs fixed above; those inputs are now inexpressible. A degenerate value (0, negative, fractional,Inf,NA, longer than one) aborts with a named message instead of failing deep inside tokenisation. Do not confusengram_maxwithedstr_view()’sngrams, which widens the window displayed around a match rather than the sizes searched.edstr_extract(): theparamsrecord reportsngram_max(the bound passed) where it used to reporttoken(the expanded sizes). This changes thesummary$paramselement of the RDS, theparamssheet of the XLSX and gt output, and theparamsblock of the JSON. Nothing else in the delivered output moves: verified byte-identical on the full 7883-document corpus for both the unigram and the1:3configurations.edstr_extract(): themismatch_dataargument is renamed tounmatched_data. It now gates only the rows of theunmatched$no_conceptset (documents whose text was searched and matched no concept), which can be large; the countunmatched$n_no_conceptis always exact regardless, and theno_source,empty_textandoutside_psets are always populated.edstr_extract(): themismatchoutput element (a list ofidandregex) is replaced by two top-level elements.unmatchedholdsid/grouptibbles split by reason (no_concept,no_source,empty_text,outside_p);mismatchedholds the token vs source discrepancies (formerlymismatch$regex).The XLSX
mismatchsheet is renamed tomismatched, and a newunmatchedsheet with areasoncolumn is added.RDS cache files written by earlier versions have an incompatible structure and must be deleted or regenerated.
New features
edstr_extract()now reports documents that produced no matchable text in the summary, split into empty or missing sources, sources holding no text once markup is stripped, and text the formatter could not extract. This replaces the previouscli_warn()warning for empty-after-formatting documents, which is no longer emitted.edstr_extract()separates documents that could not be evaluated from true negatives. A document whose source is empty orNAis reported asunmatched$no_sourceinstead of being counted with the documents that were searched and matched no concept, sounmatched$n_no_conceptgives a usable denominator (always exact, independent ofunmatched_data).
Internal changes
dplyr (>= 1.1.1)is now declared, the version that introduced the joinrelationshipargument. The package already required at least 1.1.0 forpick()without saying so.RJDBCis dropped fromImports. Oracle connections go throughDatabaseConnector, which loads the JDBC driver itself, so the declaration had no call site. Java (>= 8) is still required, byrJavaandDatabaseConnector.
edstr 0.3.0 (2026-05-02)
Breaking changes
edstr_import()andedstr_clean()now save cache files as Parquet (.parquet) instead of RDS (.rds). Existing.rdscache files from earlier versions will not be detected and must be deleted or regenerated.edstr_extract()no longer saves a.csvfile. Results are now saved as.xlsx,.json, and.rds.
Internal changes
- Code style unified across all R files with
airformatter. - Pre-commit hooks added:
air-format(posit-dev/air-pre-commit v0.9.0) andjarl-check(etiennebacher/jarl-pre-commit v0.4.0), both scoped to.Rfiles. -
.Rbuildignoreregex patterns fixed (^air\.toml$, added^jarl\.toml$). -
.Rprofileremoved (JVM auto-detection viafindno longer needed). -
DESCRIPTION: migrated from legacyRoxygen/RoxygenNotefields toConfig/roxygen2/markdownandConfig/roxygen2/version. -
dplyr::if_anyanddplyr::sliceadded to explicit imports.
edstr 0.2.0 (2026-03-26)
Breaking changes
All CLI messages, error messages, and interactive menus are now in English.
edstr_view(): theidargument now defaults toNULLinstead of auto-detecting at call time. The column is still auto-detected internally, but this avoids errors whendatais not yet available at function definition.
New features
edstr_extract()gains anexclus_auto_token_minargument (default10) to control the minimum n-gram size for automatic exclusion heuristics. Previously this was hard-coded.edstr_config()now validates all arguments on input and raises informative errors for wrong types or lengths.edstr_clean()now validates thereplaceargument structure (must be a named character vector or list of named character vectors) and checks that the text column exists indatabefore processing.