- a reference string is a key, never a Rust literal: display text is a key into `GlobalStrings.lua` in-game or `GlueStrings.lua` on the glue screens, resolved at runtime from the player's own install, and a literal is legitimate only as a fallback beside a lookup — the reference's own `UNKNOWNOBJECT` shape — because a re-typed sentence costs the wording nobody diffed, the surface and the voice a composed string has no catalog record to read off (1770, 1815), and localization; the tripwire in `reference_strings.rs` is the check a comment could never be: it walks the workspace, normalises both sides and fails on any literal matching a shipped value — 273 re-typed literals over 34 files, after calibration threw out three false-positive classes (a path-mangling bug, the 85 fallbacks beside a real lookup, and `#[cfg(test)]` modules whose fake tables match by construction — the first run's 378 would have enshrined ~50 phantom sites); `ALLOWED` is a per-file ratchet whose counts may only go down and whose stale rows fail too, regenerated from a clean tree at 253 over 33 files — the first baseline was captured mid-edit and the land gates failed on a row claiming drift in a file that had none — then 252 over 32 as `ui_trade`'s row retired behind a neighbouring conversion to `ERR_TRADE_BLOCKED_S` (2042); stated in its own doc: it cannot find an INVENTED sentence, which matches nothing; and `benilla_ui::strings::fill` is the one template filler — ordered `%s`/`%d`, positional `%N$s`, `%%` collapsing, a starved specifier copied through visibly so a mis-modelled template looks wrong rather than plausible — replacing eight hand-rolled copies that disagreed, one of them `str::replace` filling every `%s` with the same argument, latent only because no message on that queue carried two yet (2045)
- two families convert, and both assert the identifier, not the sentence: the character-create result line resolves its key off the player's chain with the literal as fallback, and three bugs the 23-arm literal table had hidden come out — `0x36` re-typed without its second sentence ("Please try again during off peak hours."), `0x50` invented (there is no `CHAR_NAME_CONSECUTIVE_SPACES` in 1.12, so it takes the default arm), and `0x4B` naming `CHAR_CREATE_NAME_IN_USE` where the code means `CHAR_NAME_RESERVED` — both read "That name is unavailable" in enUS, so no assertion on displayed text could ever have caught it, and a sentinel test that rewrites `CHAR_NAME_RESERVED` in a copy of the real table proves `0x4B` moves while `0x31` does not; the ratchet's first row retires, 21 → 0; and `SMSG_PARTY_COMMAND_RESULT` returns the catalog message the reference's own jump table names — handler `0x5e68b0`, `dec eax; cmp eax,7; ja <silent>; jmp [4*eax+0x5e6a14]`, results 1–8 onto `ERR_BAD_PLAYER_NAME_S` … `ERR_IGNORING_YOU_S` plus `ERR_INVITE_PLAYER_S` (`0x3a`) and `ERR_LEFT_GROUP_YOU` (`0x42`) on result 0 — so text, surface and sound come off one row: `ERR_PLAYER_WRONG_FACTION` (`0xff`) is the red line where the other nine are chat, and three inputs the reference is DELIBERATELY silent on (result ≥ 9, result 0 outside op {0, 2}, an invite with an empty name) stop printing an invented "Party command failed (N)."; tests assert the message id, since no English distinguishes a right key from a wrong one here, and `ui_party` goes 22 → 12 (2045)
- a hidden `<Model>` tile is parked: every orthographic pane shares the atlas's one camera, deliberately kept active whenever any cell is packed because it is the camera that clears the sheet, so `booth_frozen` — exact for every booth ever built, one camera one scene — answered "drawn" for every pane that had left the paint list, and a hidden autocast-shine pane's four spline emitters kept integrating and pushing quads for the whole 600-frame linger at a cell the packer had since given away; `sync_tiles` gains a sixth stage that parks any live tile with no cell this frame — `AnimParked` on the root, which holds the pose evaluator, the compose, the palette write and the global-sequence bone writes, and `ParticleEmitter::set_frozen` on every emitter — with `scene_frozen` stating the freeze law in one place and the park verdict pinned to the composite by a test, so a repack or a full atlas cannot freeze a visible pane's cloud; the bone global sequences move onto the pane's clock, where the material rows have been since 2019: the kernel's Phase B cursor is `(sceneClock − attachSnapshot) % duration` off the scene that owns the instance (`[model+0x2c]`, the snapshot at `CM2Model+0x68` stored at `0x70eae1`), and a `<Model>` widget owns a private `CM2Scene` at `CSimpleModel+0x314` that only its own advance at `0x76d863` moves — so the ping's spinner holds while hidden and resumes where it stopped, with no accumulator of ours; measured with a cooldown pane holding the atlas alive: twelve hidden shine panes cost 54 effect items / 63 transparent draws before, 34 / 43 after, the pool held rather than drained by design; and the particle census's `gated=` column, which printed `fade.is_some()` and read backwards as the gate verdict, is renamed `has_fade=` (2046)
- a doodad is not an entity: `[def+0xa4]` has exactly four writers image-wide — ctor `0x6a7d73` (0.0), WMO doodad-set commit `0x695bb3` (1.0), ADT MDDF commit `0x6b01bb` (1.0), MCSH refresh `0x698cb4` (0.5) — and the 2.5-producer `0x69e280` and the ramp `0x69e770` are reachable only through the entity-attached `[obj+0xe0]` path, which contradicted the §6 both lit-ground sites cited and a capture with gains up to 2.5, so the premise was marked contested at both sites first (the shader's own note told a future session to cut `min(I, 1)` and `LIT_T` together on its strength); wow-re then settled it — two C++ classes inline the same base and share the offset: `CMapDoodadDef` (vtable `0x810a74`, `"WDOODADDEF"`, size `0x198`, serving ADT MDDF and WMO MODD) and the WENTITY light node (vtable `0x810810`, size `0x108`, off `[obj+0xe0]`) that `0x69e770` ramps at 3.3333/s; on the doodad class `+0xf8`, the field the ramp reads its target from at `0x69e894`, is `m[2][3]` of the world matrix — it has no target field — and six trace frames put 111 MDDF placements over 356 draws at gains only in {0.5, 1.0}, none changing, against an entity walking out of shadow at 0.5 / 0.81 / 1.09 / 1.3367 and rising; so ADT map doodads join the fixed-1.0 family with the exterior WMO props, `ShadeSel::Lit` is entity-only, the retained lane's Matte arm drops its prop requirement, and no pixel changes — the cap was faithful for trees all along and lifting it is now an entity-only look change for the director to call; `WORD_SHADE_LIT` is left as a documented dead bit (2050); and the far water's 1.25 s breath is flattened: the director's recording autocorrelates at lag 75 @ 60 fps = 1.250 s (r = +0.60), the 30-frame ocean flipbook at 24 fps, whose frames do not share a mean — alpha 55.985..57.628, DXT3's 4-bit alpha swinging 1.64 at mip 0, 3.72 at mip 5, 6.38 at mip 6 — so at distance the mipped sheet moves together, 0.8..1.84/255 on screen, fogged out at `farclip` 300 and visible at 777; wow-re proved the reference breathes too (`0x68abab` creates the flipbook with `applyGlobal = 1`, the upload hands the driver `Levels = 9`, LOD bias 0, the loop period `[0x8102b8]` 1.25 s over 30 frames), so ours was faithful and it is flattened anyway by the director's eye: each frame's DC equalised per level and per channel at texture-build time as an exact integer redistribution in ±1 steps on a coprime stride (1.64 / 3.72 / 6.38 / 0.555 → 0.000), water and ocean only since magma and slime take the sheet as their body colour, `WOW_LIQUID_DC=raw` restoring the authored art for the A/B (2051)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
405 lines
16 KiB
Rust
405 lines
16 KiB
Rust
//! **No user-facing sentence is written in Rust when the reference ships one.**
|
|
//!
|
|
//! The real client never composes display text: every sentence is a key into `GlobalStrings.lua`
|
|
//! (in-game) or `GlueStrings.lua` (the login/character screens), resolved at runtime from the
|
|
//! player's own install. Writing the English in Rust throws away three things at once —
|
|
//! localization, and, for anything that goes through the message catalog, the *surface* the
|
|
//! message is shown on and the *voice line* it speaks with (decisions 1770, 1815, 2035).
|
|
//!
|
|
//! **Why a tripwire and not a review rule.** Decision 2035 found a loot-refusal table that had
|
|
//! composed its own eight sentences, six of which said something 1.12 never says, under a comment
|
|
//! asserting they were quoted from the reference. The comment was the only check there had ever
|
|
//! been, and a comment cannot fail. What makes that class *findable* is that a re-typed string is
|
|
//! byte-identical to the shipped one — so a walk that normalises both sides and compares them finds
|
|
//! every instance mechanically, which is what this does.
|
|
//!
|
|
//! **What it cannot find, stated plainly.** Text that matches nothing is invisible here: an
|
|
//! *invented* sentence (2035's "Those pockets are already empty.", for a code 1.12 has no string
|
|
//! for) has no shipped counterpart to match against. This tripwire catches the re-typing class,
|
|
//! which is the large one; the invention class needs a reader who checks the reference.
|
|
//!
|
|
//! **A literal beside a key lookup is correct and is not flagged** — a fallback for an install
|
|
//! whose chain lacks the key is the reference's own `UNKNOWNOBJECT` shape. The walk only counts a
|
|
//! literal whose enclosing function resolves nothing.
|
|
//!
|
|
//! [`ALLOWED`] is a **ratchet**: it records what was already drifted when the tripwire was built,
|
|
//! per file, and the counts may only ever go **down**. Converting a file means lowering its number
|
|
//! (or deleting the row); a file not listed may carry none at all. Skips without client data.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// The drift still standing, per file: **252 literals over 32 files**, each a sentence the
|
|
/// reference ships and we re-typed.
|
|
///
|
|
/// **These numbers may only decrease.** They are not a budget to spend; every one is a string that
|
|
/// will not translate, and some carry a surface and a voice line with them. Lower the count when
|
|
/// you convert a file, and delete the row at zero — a row left stale fails this test too, so a
|
|
/// conversion cannot quietly re-permit the drift it retired. `char_create` (2045) and `ui_trade`
|
|
/// (2042, a neighbouring session) are both converted and have no row: that is what retiring one
|
|
/// looks like, and the second was caught by this test rather than by remembering.
|
|
///
|
|
/// **Regenerate these against a clean tree, never mid-edit.** The walk is deterministic on the
|
|
/// sources plus the shipped tables (two consecutive runs agree exactly), but a baseline captured
|
|
/// while other files are half-converted records counts that no later tree will reproduce — which
|
|
/// is exactly how the first cut of this list carried a row the gates then failed on.
|
|
const ALLOWED: &[(&str, usize)] = &[
|
|
("benilla-app/src/ui_chat/frames.rs", 48),
|
|
("benilla-app/src/ui_guild/lines.rs", 34),
|
|
("benilla-app/src/ui_tooltip/mod.rs", 28),
|
|
("benilla-app/src/ui_party/mod.rs", 12),
|
|
("benilla-app/src/ui_social/mod.rs", 18),
|
|
("benilla-app/src/ui_petition/lines.rs", 15),
|
|
("benilla-app/src/ui_chat/feed.rs", 13),
|
|
("benilla-ui/src/script/tooltip_item/render.rs", 9),
|
|
("benilla-app/src/ui_unit.rs", 6),
|
|
("benilla-formats/src/spells/tokens.rs", 6),
|
|
("benilla-app/src/ui_duel.rs", 5),
|
|
("benilla-app/src/ui_social/feed.rs", 4),
|
|
("benilla-app/src/ui_talent.rs", 4),
|
|
("benilla-ui/src/script/tooltip_unit.rs", 4),
|
|
("benilla-app/src/ui_quest.rs", 3),
|
|
("benilla-app/src/ui_quest_log.rs", 3),
|
|
("benilla-app/src/ui_script/resolve_bench.rs", 3),
|
|
("benilla-ui/src/script/tooltip_item/names.rs", 3),
|
|
("benilla-ui/src/script/tradeskill/view.rs", 3),
|
|
("benilla-app/src/net/apply/chat.rs", 2),
|
|
("benilla-app/src/ui_binder.rs", 2),
|
|
("benilla-app/src/ui_char.rs", 2),
|
|
("benilla-app/src/ui_trainer/law.rs", 2),
|
|
("benilla-app/src/ui_trainer/mod.rs", 2),
|
|
("benilla-ui/src/script/tooltip_item/mod.rs", 2),
|
|
("benilla-ui/src/script/tooltip_spell.rs", 2),
|
|
("benilla-ui/src/script/unit/mod.rs", 2),
|
|
("benilla-app/src/death.rs", 1),
|
|
("benilla-app/src/net/apply/quests.rs", 1),
|
|
("benilla-app/src/ui_chat/input/mod.rs", 1),
|
|
("benilla-app/src/ui_items/feed.rs", 1),
|
|
("benilla-ui/src/script/talent.rs", 1),
|
|
];
|
|
|
|
/// Paths that are never player-facing: dev instruments, probes, capture harnesses and benches.
|
|
/// A `.learn <spell>` GM command is not a UI string even when it collides with one.
|
|
fn is_instrument(rel: &str) -> bool {
|
|
[
|
|
"/capture/",
|
|
"/bin/",
|
|
"/probes/",
|
|
"debug_panel",
|
|
"shape_gate",
|
|
"framexml_diff",
|
|
]
|
|
.iter()
|
|
.any(|p| rel.contains(p))
|
|
}
|
|
|
|
/// Collapse a shipped value and a Rust literal onto the same shape: every placeholder — the
|
|
/// reference's `%s`/`%d`/`%1$s` and Rust's `{}`/`{name}` alike — becomes one marker, `\32` is the
|
|
/// escaped space it stands for, runs of whitespace collapse, and case is dropped.
|
|
fn normalize(s: &str) -> String {
|
|
let s = s.replace("\\32", " ");
|
|
let mut out = String::with_capacity(s.len());
|
|
let b: Vec<char> = s.chars().collect();
|
|
let mut i = 0;
|
|
while i < b.len() {
|
|
match b[i] {
|
|
'{' => {
|
|
// a Rust format hole
|
|
while i < b.len() && b[i] != '}' {
|
|
i += 1;
|
|
}
|
|
i += 1;
|
|
out.push('\u{1}');
|
|
}
|
|
'%' => {
|
|
let mut j = i + 1;
|
|
while j < b.len()
|
|
&& (b[j].is_ascii_digit() || b[j] == '$' || b[j] == '.' || b[j] == '-')
|
|
{
|
|
j += 1;
|
|
}
|
|
if j < b.len() && matches!(b[j], 's' | 'd' | 'f' | 'c' | 'g') {
|
|
out.push('\u{1}');
|
|
i = j + 1;
|
|
} else {
|
|
out.push('%');
|
|
i += 1;
|
|
}
|
|
}
|
|
c => {
|
|
out.push(c.to_ascii_lowercase());
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
/// Parse `KEY = "value";` out of a Lua string table.
|
|
fn lua_table(src: &str) -> HashMap<String, String> {
|
|
let mut out = HashMap::new();
|
|
for line in src.lines() {
|
|
let Some((k, rest)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
let k = k.trim();
|
|
if k.is_empty()
|
|
|| !k
|
|
.bytes()
|
|
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_')
|
|
{
|
|
continue;
|
|
}
|
|
let rest = rest.trim();
|
|
let Some(rest) = rest.strip_prefix('"') else {
|
|
continue;
|
|
};
|
|
if let Some(end) = rest.rfind("\";") {
|
|
out.insert(k.to_string(), rest[..end].to_string());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn walk(dir: &Path, into: &mut Vec<PathBuf>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
if path.is_dir() {
|
|
if !matches!(name, "target" | ".git" | "tests" | "benches" | "examples") {
|
|
walk(&path, into);
|
|
}
|
|
} else if name.ends_with(".rs") && !name.contains("test") {
|
|
into.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Does this function resolve a key at all? If so, a literal inside it is a fallback.
|
|
const RESOLVERS: &[&str] = &[
|
|
"strings.get(",
|
|
".text(",
|
|
"keyed_line",
|
|
"globals().get::<String>",
|
|
"GlueStrings",
|
|
"by_key(",
|
|
"UiError::key",
|
|
"glue_strings",
|
|
];
|
|
|
|
#[test]
|
|
fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
|
let data = match benilla_formats::wow_data() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let mut shipped = HashMap::new();
|
|
for file in [
|
|
"Interface\\FrameXML\\GlobalStrings.lua",
|
|
"Interface\\GlueXML\\GlueStrings.lua",
|
|
] {
|
|
let src = chain
|
|
.read_file(file)
|
|
.unwrap_or_else(|e| panic!("{file}: {e}"));
|
|
for (k, v) in lua_table(&String::from_utf8_lossy(&src)) {
|
|
let n = normalize(&v);
|
|
// A one-word or punctuation-only value ("Locked", "%s") is too weak a signal: it
|
|
// collides with ordinary program text. Sentences are what this is after.
|
|
if n.split(' ').count() >= 2 && n.chars().any(|c| c.is_ascii_lowercase()) {
|
|
shipped.entry(n).or_insert(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut sources = Vec::new();
|
|
walk(Path::new("src"), &mut sources);
|
|
walk(Path::new("../benilla-ui/src"), &mut sources);
|
|
walk(Path::new("../benilla-formats/src"), &mut sources);
|
|
|
|
let allowed: HashMap<&str, usize> = ALLOWED.iter().copied().collect();
|
|
let mut found: HashMap<String, Vec<String>> = HashMap::new();
|
|
|
|
for path in &sources {
|
|
// The walk starts at this crate's `src` and at the two sibling crates by relative path;
|
|
// name every hit `<crate>/src/...` so the ratchet rows read the same wherever it ran.
|
|
let raw = path.to_string_lossy().to_string();
|
|
let rel = match raw.strip_prefix("../") {
|
|
Some(sibling) => sibling.to_string(),
|
|
None => format!("benilla-app/{raw}"),
|
|
};
|
|
if is_instrument(&rel) {
|
|
continue;
|
|
}
|
|
let src = strip_test_modules(&std::fs::read_to_string(path).expect("read source"));
|
|
for (fname, body) in split_fns(&src) {
|
|
if RESOLVERS.iter().any(|r| body.contains(r)) {
|
|
continue; // a literal here is a fallback beside a real lookup
|
|
}
|
|
for lit in string_literals(body) {
|
|
let n = normalize(&lit);
|
|
if n.split(' ').count() < 2 {
|
|
continue;
|
|
}
|
|
if let Some(key) = shipped.get(&n) {
|
|
found
|
|
.entry(rel.clone())
|
|
.or_default()
|
|
.push(format!("{key} — {fname}: {lit:?}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut over = Vec::new();
|
|
for (file, hits) in &found {
|
|
let budget = allowed.get(file.as_str()).copied().unwrap_or(0);
|
|
if hits.len() > budget {
|
|
over.push(format!(
|
|
"\n {file}: {} literals, ratchet allows {budget}\n{}",
|
|
hits.len(),
|
|
hits.iter()
|
|
.map(|h| format!(" {h}\n"))
|
|
.collect::<String>()
|
|
));
|
|
}
|
|
}
|
|
// The ratchet only bites downward if a *stale* row is an error too — otherwise a converted
|
|
// file's row lingers and silently re-permits the drift it was meant to retire.
|
|
for (file, budget) in ALLOWED {
|
|
let actual = found.get(*file).map_or(0, |h| h.len());
|
|
assert!(
|
|
actual >= *budget,
|
|
"\nRATCHET STALE: {file} now has {actual} re-typed literals but its row still allows \
|
|
{budget}.\nLower it to {actual} (or delete the row at 0) — the count may only go down."
|
|
);
|
|
}
|
|
assert!(
|
|
over.is_empty(),
|
|
"\nA user-facing sentence is written in Rust where the reference ships the string.\n\
|
|
Resolve it by key instead — `keyed_line`/`keyed_line_s` where a `UiScript` is in hand, \
|
|
`UiError::key` into a queue at the net bridge, `GlueStrings::text` on the glue screens.\n\
|
|
(decision 2035){}",
|
|
over.join("")
|
|
);
|
|
}
|
|
|
|
/// Cut every `#[cfg(test)]` block out of a source file before scanning it.
|
|
///
|
|
/// Without this the walk reads test fixtures as production text — `ui_action::cast_fail`'s unit
|
|
/// tests build a *fake* GlobalStrings map whose 48 entries are, by construction, byte-identical to
|
|
/// the shipped strings. Those are the test doing its job, not drift.
|
|
fn strip_test_modules(src: &str) -> String {
|
|
let mut out = String::with_capacity(src.len());
|
|
let mut rest = src;
|
|
while let Some(at) = rest.find("#[cfg(test)]") {
|
|
out.push_str(&rest[..at]);
|
|
// skip to the opening brace of the item that attribute guards, then brace-match it
|
|
let after = &rest[at..];
|
|
let Some(open) = after.find('{') else { break };
|
|
let mut depth = 0usize;
|
|
let mut end = None;
|
|
for (i, c) in after[open..].char_indices() {
|
|
match c {
|
|
'{' => depth += 1,
|
|
'}' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
end = Some(open + i + 1);
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
match end {
|
|
Some(e) => rest = &after[e..],
|
|
None => break,
|
|
}
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
/// Split a source file into `(fn name, body)` pairs — crude but enough to ask "does the enclosing
|
|
/// function resolve a key?", which is the only question here.
|
|
fn split_fns(src: &str) -> Vec<(&str, &str)> {
|
|
let mut out = Vec::new();
|
|
let bytes = src.as_bytes();
|
|
let mut starts: Vec<(usize, &str)> = Vec::new();
|
|
for (i, _) in src.match_indices("fn ") {
|
|
// a top-levelish `fn`: preceded only by whitespace/visibility on its line
|
|
let line_start = src[..i].rfind('\n').map_or(0, |n| n + 1);
|
|
let prefix = &src[line_start..i];
|
|
if !prefix.trim_start().is_empty()
|
|
&& !matches!(
|
|
prefix.trim(),
|
|
"pub" | "pub(crate)" | "pub(super)" | "const" | "async"
|
|
)
|
|
&& !prefix.trim().starts_with("pub(")
|
|
{
|
|
continue;
|
|
}
|
|
let rest = &src[i + 3..];
|
|
let name_end = rest
|
|
.find(|c: char| !c.is_alphanumeric() && c != '_')
|
|
.unwrap_or(rest.len());
|
|
starts.push((i, &rest[..name_end]));
|
|
}
|
|
for (n, (i, name)) in starts.iter().enumerate() {
|
|
let end = starts.get(n + 1).map_or(bytes.len(), |(j, _)| *j);
|
|
out.push((*name, &src[*i..end]));
|
|
}
|
|
if out.is_empty() {
|
|
out.push(("<file>", src));
|
|
} else if let Some((first, _)) = starts.first() {
|
|
out.push(("<consts>", &src[..*first])); // module-level `const … : &str = "…"`
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every double-quoted literal in a chunk, skipping comment lines and developer-facing macros.
|
|
fn string_literals(body: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
for line in body.lines() {
|
|
let t = line.trim_start();
|
|
if t.starts_with("//") {
|
|
continue;
|
|
}
|
|
if [
|
|
"debug!",
|
|
"info!",
|
|
"warn!",
|
|
"error!",
|
|
"trace!",
|
|
"println!",
|
|
"eprintln!",
|
|
"panic!",
|
|
"assert",
|
|
"unreachable!",
|
|
"todo!",
|
|
".expect(",
|
|
"unwrap_or_else",
|
|
]
|
|
.iter()
|
|
.any(|m| line.contains(m))
|
|
{
|
|
continue;
|
|
}
|
|
let mut rest = line;
|
|
while let Some(start) = rest.find('"') {
|
|
let after = &rest[start + 1..];
|
|
let Some(end) = after.find('"') else { break };
|
|
let lit = &after[..end];
|
|
if lit.len() >= 6 && lit.contains(' ') && lit.chars().any(|c| c.is_ascii_lowercase()) {
|
|
out.push(lit.to_string());
|
|
}
|
|
rest = &after[end + 1..];
|
|
}
|
|
}
|
|
out
|
|
}
|