Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 006a087c09 address review: trim colour text before length limit 2026-11-03 22:21:08 +00:00
Dylan M. Taylor c0f59f9c52 fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines)
DOF scanned the selection list per slot; the xref palette scanned the xref list per INSERT; QSELECT parsed a possibly long layer name as a colour for every entity; and a log message could carry CR/LF or other control characters that started apparent extra records. Each now uses a set or map, a length bound, or escaping, with the same results for ordinary input.
2026-11-03 17:00:43 +00:00
8 changed files with 82 additions and 73 deletions
+22 -3
View File
@@ -148,7 +148,20 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
// A message can carry text from a file (a code page name, a path), so it must not be able to
// start what looks like another record: control characters other than tab and line feed are
// shown escaped, and every continuation line is indented, so only this function's own
// timestamp is ever at the start of a line.
let mut safe = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => safe.push_str("\n "),
'\t' => safe.push('\t'),
c if c.is_control() => safe.extend(c.escape_default()),
c => safe.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -441,8 +454,14 @@ mod tests {
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
// A multi-line message keeps its lines, indented so they can't pass for records; the
// record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
// Control characters in a message (from a file) can't forge a record.
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
assert_eq!(forged.lines().count(), 2);
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
}
#[test]
+3 -1
View File
@@ -138,7 +138,9 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
let selected: HashSet<Handle> = handles.iter().copied().collect();
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
let mut total = Some(0usize);
+4 -5
View File
@@ -234,10 +234,9 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
missing("table style", "Standard", out);
d.table_styles.insert(0, TableStyle::default());
}
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
for l in &mut d.layers {
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
out.push(issue(
"missingLinetype",
"tables",
@@ -248,8 +247,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
l.linetype = "Continuous".into();
}
}
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
for s in &mut d.dim_styles {
if !known(&s.text_style) {
out.push(issue(
+4 -32
View File
@@ -408,24 +408,12 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
let d = s.doc_mut()?;
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
restore_layers(d, st.layers);
ok()
}
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
/// drawing's layers are looked up in an index rather than scanned per saved layer.
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
for layer in saved {
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
*l = layer;
for saved in st.layers {
if let Some(l) = d.layer_mut(&saved.name) {
*l = saved;
}
}
ok()
}
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -473,22 +461,6 @@ mod tests {
use crate::Session;
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
#[test]
fn restore_layers_matches_by_name_in_linear_time() {
let mut d = cadcraft_doc::Drawing::new_imperial();
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
let mut back = cadcraft_doc::Layer::new("l7");
back.frozen = true;
saved.push(back);
let started = std::time::Instant::now();
super::restore_layers(&mut d, saved);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(d.layer("L7").is_some_and(|l| l.frozen));
assert_eq!(d.layers.len(), 20_000);
}
#[test]
fn layer_states_save_list_restore_delete() {
let mut s = Session::new();
+31 -2
View File
@@ -175,8 +175,14 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
return true;
}
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
// long text (a layer name from a file) isn't parsed, copied and split per entity.
const MAX_COLOR_TEXT: usize = 64;
let (pc, vc) = (ptxt.trim(), vtxt.trim());
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
return false;
}
match (Color::parse(pc), Color::parse(vc)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
@@ -400,3 +406,26 @@ mod tests {
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
}
}
#[cfg(test)]
mod long_text_tests {
use super::*;
/// A long string property is compared as text only, and a colour name still equals its index.
#[test]
fn long_values_skip_colour_parsing() {
let long = Value::String("x".repeat(100_000));
assert!(compare(&long, Op::Eq, &long));
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
}
/// Padding is trimmed before the length limit, so a padded colour still matches.
#[test]
fn padded_colours_still_match() {
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
}
}
+4 -28
View File
@@ -1,8 +1,6 @@
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
use std::collections::HashMap;
use cadcraft_geom::Vec2;
use serde_json::{Map, Value, json};
@@ -69,9 +67,6 @@ struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a Map<String, Value>,
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
/// case-insensitive lookup is not a scan of the map per reference.
folded: HashMap<String, &'a Value>,
depth: usize,
unary_depth: usize,
}
@@ -235,7 +230,7 @@ impl Parser<'_> {
match name.as_str() {
"pi" => Ok(Val::Num(std::f64::consts::PI)),
"e" => Ok(Val::Num(std::f64::consts::E)),
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
@@ -244,8 +239,8 @@ impl Parser<'_> {
}
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
if let Some(n) = v.as_f64() {
return Some(Val::Num(n));
}
@@ -416,11 +411,7 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
if expr.len() > MAX_LEN {
return Err("expression too long".into());
}
let mut folded = HashMap::with_capacity(vars.len());
for (k, v) in vars {
folded.entry(k.to_ascii_lowercase()).or_insert(v);
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
@@ -596,21 +587,6 @@ mod tests {
eval_val(s, &Map::new())
}
/// Variables are found without regard to case; an exact match wins over a case variant.
/// (Names are read lower-cased, so `X` finds `x` too.)
#[test]
fn variables_ignore_case_and_prefer_exact_names() {
let mut vars = Map::new();
for i in 0..10_000 {
vars.insert(format!("v{i}"), json!(i));
}
vars.insert("X".into(), json!(1));
vars.insert("x".into(), json!(2));
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
assert!(eval_val("nope", &vars).is_err());
}
#[test]
fn points_and_vectors() {
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
+7 -1
View File
@@ -185,7 +185,13 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
if let Some(state) = &v.layer_state {
let d = s.doc_mut()?;
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
Some(saved) => {
for layer in saved.layers {
if let Some(l) = d.layer_mut(&layer.name) {
*l = layer;
}
}
}
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
}
}
+7 -1
View File
@@ -78,10 +78,16 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
let mut counts = vec![0usize; xrefs.len()];
if !xrefs.is_empty() {
// Xref name (ASCII-upper-cased) → its row, the first of names that differ only in case, so
// each INSERT is one lookup instead of a scan of the xref list.
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (k, b) in xrefs.iter().enumerate() {
index.entry(b.name.to_ascii_uppercase()).or_insert(k);
}
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
for e in store.iter() {
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
&& let Some(&k) = index.get(&i.block.to_ascii_uppercase())
&& let Some(c) = counts.get_mut(k)
{
*c += 1;