mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-09 20:10:12 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6918281271 | ||
|
|
44ade5aaed | ||
|
|
65ff2f42f4 |
@@ -250,18 +250,17 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Value::Object(mut obj) = msg else {
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(Value::String(method)) = obj.remove("method") else {
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
// Moved out of the parsed object, not cloned: `params` can be most of the line.
|
||||
Line::Request {
|
||||
id: obj.remove("id").unwrap_or(Value::Null),
|
||||
method,
|
||||
params: obj.remove("params").unwrap_or(json!({})),
|
||||
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,16 +178,8 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
|
||||
/// default MAXARRAY).
|
||||
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
|
||||
|
||||
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
|
||||
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
|
||||
///
|
||||
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
|
||||
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
|
||||
/// takes the array block's own items.
|
||||
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let p = d.array_params(e)?;
|
||||
@@ -201,12 +193,7 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
_ => None,
|
||||
};
|
||||
match source {
|
||||
Some((im, b)) => {
|
||||
if out.len().saturating_add(b.entities.len()) > MAX_EXPLODED_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
|
||||
}
|
||||
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
|
||||
None => out.push(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! block name and values, or one row per reference with its handle, layer, position and
|
||||
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert};
|
||||
use cadcraft_doc::{EntityKind, Insert};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -88,67 +88,20 @@ fn matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| wildcard(p.trim(), text))
|
||||
}
|
||||
|
||||
/// The constant attribute values of a block definition whose tags are selected for output.
|
||||
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& selected.iter().any(|tag| tag == &a.tag.to_ascii_uppercase())
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
|
||||
/// block (`consts`, read once per block). Values no column shows are not copied.
|
||||
fn values(ins: &Insert, consts: &[(String, String)], selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins
|
||||
.attribs
|
||||
.iter()
|
||||
.map(|a| (a.tag.to_ascii_uppercase(), a))
|
||||
.filter(|(t, _)| selected.contains(t))
|
||||
.map(|(t, a)| (t, a.text.value.clone()))
|
||||
.collect();
|
||||
for (t, val) in consts {
|
||||
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
|
||||
v.push((t.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Select the output columns before copying any attribute text. A block definition can contain
|
||||
/// many large constants that do not survive the table's 190-column limit.
|
||||
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
|
||||
let mut selected = Vec::new();
|
||||
let mut add = |tag: &str| {
|
||||
let tag = tag.to_ascii_uppercase();
|
||||
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
|
||||
selected.push(tag);
|
||||
}
|
||||
};
|
||||
for entity in ents {
|
||||
let EntityKind::Insert(ins) = &entity.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
if anonymous || !matches(&o.blocks, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for attribute in &ins.attribs {
|
||||
add(&attribute.tag);
|
||||
}
|
||||
if let Some(block) = d.block(&ins.block) {
|
||||
for entity in block.entities.iter() {
|
||||
if let EntityKind::AttDef(attribute) = &entity.kind && attribute.constant {
|
||||
add(&attribute.tag);
|
||||
}
|
||||
/// The attribute values of `ins`: its own, then the constant ones of its block.
|
||||
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
|
||||
if let Some(b) = d.block(&ins.block) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
v
|
||||
}
|
||||
|
||||
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
||||
@@ -159,9 +112,7 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
} else {
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
|
||||
};
|
||||
let selected = selected_tags(&ents, d, o);
|
||||
let mut out = Vec::new();
|
||||
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for e in &ents {
|
||||
let EntityKind::Insert(ins) = &e.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
@@ -170,17 +121,12 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
out.push(Found {
|
||||
block,
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
block: block.clone(),
|
||||
values: values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
),
|
||||
values: values(d, ins),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("-xref", "External References", run_dash_xref)
|
||||
.alias(&["-xr"])
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
|
||||
CommandSpec::new("xbind", "Bind...", run_xbind)
|
||||
.menu(&["Modify", "Object", "External Reference", "Bind..."])
|
||||
@@ -201,6 +201,38 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The xrefs a JSON call names (`names`, default all); an error when none match.
|
||||
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
|
||||
if p.get("exact").and_then(Value::as_bool) == Some(true) {
|
||||
let lits: Vec<&str> = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
|
||||
Some(Value::String(t)) => vec![t.as_str()],
|
||||
_ => str_param(p, "name").into_iter().collect(),
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let mut names = Vec::new();
|
||||
let mut selected = std::collections::HashSet::new();
|
||||
for literal in &lits {
|
||||
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
|
||||
if selected.insert(block.name.to_lowercase()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let normalized = literal.to_lowercase();
|
||||
let mut matches = xrefs(d).filter(|block| block.name.to_lowercase() == normalized);
|
||||
if let Some(block) = matches.next() {
|
||||
if matches.next().is_some() {
|
||||
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
|
||||
}
|
||||
if selected.insert(block.name.to_lowercase()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
|
||||
}
|
||||
return Ok(names);
|
||||
}
|
||||
let pattern = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
|
||||
Some(Value::String(t)) => t.clone(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -375,20 +375,16 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
|
||||
let mut boxes = Vec::new();
|
||||
// One block-bounds cache for each drawing, so a block many differing entities refer to is
|
||||
// measured once, not once per entity.
|
||||
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
|
||||
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
|
||||
{
|
||||
let mut bound = |d: &Drawing, h: Handle| {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
||||
boxes.push(b);
|
||||
}
|
||||
}
|
||||
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
|
||||
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
|
||||
};
|
||||
added.iter().for_each(|h| bound(&cur, *h));
|
||||
removed.iter().for_each(|h| bound(&other, *h));
|
||||
for (a, b, _, _) in &changed {
|
||||
bound(&mut boxes, &cur, *a, &mut cur_cache);
|
||||
bound(&mut boxes, &other, *b, &mut other_cache);
|
||||
bound(&cur, *a);
|
||||
bound(&other, *b);
|
||||
}
|
||||
let regions = regions(&boxes);
|
||||
let ch: Vec<Value> = changed
|
||||
|
||||
@@ -26,11 +26,31 @@ const MAX_PURGE_PASSES: usize = 64;
|
||||
|
||||
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||
let file = format!("{layout}.dwt");
|
||||
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||
None => file,
|
||||
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
|
||||
// beside the drawing, so those become `_`.
|
||||
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
|
||||
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
|
||||
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
|
||||
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
|
||||
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file),
|
||||
None => std::path::PathBuf::from(file),
|
||||
};
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().into_owned();
|
||||
}
|
||||
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
|
||||
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
|
||||
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
|
||||
for suffix in 2u64.. {
|
||||
let alternative = parent.join(format!("{stem}-{suffix}.{extension}"));
|
||||
if !alternative.exists() {
|
||||
return alternative.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
unreachable!("the default layout template filename suffix space is exhausted")
|
||||
}
|
||||
|
||||
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||
@@ -133,3 +153,32 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod default_path_tests {
|
||||
use super::*;
|
||||
|
||||
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
|
||||
#[test]
|
||||
fn layout_name_is_one_file_name() {
|
||||
let s = Session::empty();
|
||||
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
|
||||
let p = default_path(&s, name);
|
||||
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
|
||||
assert!(p.ends_with(".dwt"));
|
||||
}
|
||||
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
|
||||
}
|
||||
|
||||
/// Windows device names are prefixed so the suggestion is never a device.
|
||||
#[test]
|
||||
fn reserved_device_names_are_prefixed() {
|
||||
let s = Session::empty();
|
||||
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
|
||||
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
|
||||
}
|
||||
for name in ["COM0", "CONSOLE", "COM10"] {
|
||||
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +117,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
|
||||
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
|
||||
let live = counts(s, &objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
@@ -156,12 +150,6 @@ pub(super) struct MocoroM {
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
if matches!(op, Op::Copy(_)) {
|
||||
let live = counts(s, &self.objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, 1, live)?;
|
||||
}
|
||||
}
|
||||
let before = s.state()?.doc.clone();
|
||||
let base = self.base;
|
||||
apply(s, &self.objs, &mut self.base, op)?;
|
||||
|
||||
@@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
|
||||
|
||||
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
|
||||
pub fn wildcard(pat: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
|
||||
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
|
||||
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
|
||||
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
|
||||
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
|
||||
// Iterative matcher with single back-track point (linear-ish, no recursion).
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
|
||||
@@ -27,7 +27,10 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
|
||||
if !writing {
|
||||
match read_text_file(&path) {
|
||||
Some(t) => {
|
||||
charge_bytes(t.len())?;
|
||||
// Each line has a String header and a separately allocated buffer. Charge a
|
||||
// conservative 64 bytes per line for both, including the allocator's minimum
|
||||
// allocation size, before collecting so many short lines cannot evade the budget.
|
||||
charge_bytes(t.len().saturating_add(t.lines().count().saturating_mul(64)))?;
|
||||
f.lines = t.lines().map(str::to_string).collect();
|
||||
}
|
||||
None => return Ok(Flow::Value(Value::Nil)),
|
||||
@@ -100,8 +103,19 @@ fn can_write(_path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_text_file(path: &str, text: &str, append: bool) {
|
||||
use std::io::Write;
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
|
||||
if let Ok(mut f) = f {
|
||||
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
|
||||
// instead of stalling the interpreter; only a regular file is then truncated and written.
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.create(true).write(true).append(append);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
if let Ok(mut f) = o.open(path)
|
||||
&& f.metadata().is_ok_and(|m| m.is_file())
|
||||
&& (append || f.set_len(0).is_ok())
|
||||
{
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -118,3 +132,29 @@ fn file_exists(path: &str) -> bool {
|
||||
fn file_exists(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
|
||||
#[test]
|
||||
fn write_to_a_fifo_without_a_reader_does_not_block() {
|
||||
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
write_text_file(fifo.to_str().unwrap(), "x", false);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
let f = dir.join("f.txt");
|
||||
std::fs::write(&f, "long old text").unwrap();
|
||||
write_text_file(f.to_str().unwrap(), "new", false);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
|
||||
write_text_file(f.to_str().unwrap(), "+", true);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,17 +233,3 @@ fn options_on_objects_that_are_gone_record_nothing() {
|
||||
assert!(lines(&s).is_empty());
|
||||
assert_eq!(undo_len(&s), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_copies_stay_within_maxarray() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
|
||||
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
|
||||
let mut hs = vec![l];
|
||||
hs.extend(others);
|
||||
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
|
||||
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert_eq!(lines(&s).len(), 30, "nothing was copied");
|
||||
}
|
||||
|
||||
@@ -190,6 +190,11 @@ fn reload_unload_path_and_detach() {
|
||||
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
|
||||
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
|
||||
|
||||
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
|
||||
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
|
||||
|
||||
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
|
||||
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
|
||||
assert!(s.doc().unwrap().block("site|Tree").is_none());
|
||||
|
||||
+28
-3
@@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
rerun_if_changed(&manifest)?;
|
||||
// Resolve the checkout root once so every manifest file can be confined inside it.
|
||||
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
|
||||
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
|
||||
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
// The line's text is not repeated: it comes from the checkout and goes to the build output.
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
return Err(format!("malformed manifest line {n}"));
|
||||
};
|
||||
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
|
||||
@@ -73,6 +76,28 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most a manifest may hold; the real one is a few KiB.
|
||||
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
|
||||
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
|
||||
use std::io::Read as _;
|
||||
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !file.metadata().is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
|
||||
///
|
||||
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
|
||||
|
||||
+3
-17
@@ -50,8 +50,6 @@ const MAX_LAYER_NAME: usize = 1000;
|
||||
/// Most link annotations in one file, and the longest link target (bytes).
|
||||
const MAX_LINKS: usize = 10_000;
|
||||
const MAX_URL: usize = 2048;
|
||||
/// The most link annotations in one PDF, over all pages (a page holds at most [`MAX_LINKS`]).
|
||||
const MAX_FILE_ANNOTATIONS: usize = 100_000;
|
||||
/// Longest title and page label written (characters).
|
||||
const MAX_TEXT: usize = 1000;
|
||||
|
||||
@@ -359,10 +357,7 @@ fn sheet_name(space: &Space) -> String {
|
||||
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
|
||||
/// layout name. The options apply to every sheet; layouts use their own page setups.
|
||||
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
|
||||
// Links are file-wide (`pdf_items` reads them from `o`), so a sheet's own options carry none:
|
||||
// a copy of the list per sheet multiplied it by the sheet count.
|
||||
let sheet = PdfOptions { links: Vec::new(), ..o.clone() };
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), sheet.clone())).collect();
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
|
||||
pdf_items(d, &items, o)
|
||||
}
|
||||
|
||||
@@ -458,16 +453,8 @@ fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut fonts = fonts::Fonts::new(!text_as_geometry);
|
||||
let mut groups = Groups::default();
|
||||
// Link annotations of the whole file: each page keeps its own copy of the targets, so the
|
||||
// pages times the links must not multiply without a bound.
|
||||
let mut annotations_left = MAX_FILE_ANNOTATIONS;
|
||||
for (s, po) in items {
|
||||
// Once the budget is spent no link is built (scanned and cloned) for later pages.
|
||||
let mut page = sheet(drawn, s, po, if annotations_left == 0 { &links[..0] } else { &links })?;
|
||||
// Only targets `write_page` accepts count against the budget.
|
||||
page.links.retain(|(_, url)| uri(url).is_some());
|
||||
page.links.truncate(annotations_left);
|
||||
annotations_left -= page.links.len();
|
||||
let page = sheet(drawn, s, po, &links)?;
|
||||
let doc = writer.document_mut();
|
||||
// Groups are added to the document, so they must exist before the page starts.
|
||||
groups.add(doc, d, &page.layers);
|
||||
@@ -638,8 +625,7 @@ pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Shee
|
||||
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
|
||||
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
|
||||
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
|
||||
// A target past MAX_URL is refused at the annotation anyway; it is left out before it is copied.
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().filter(|(_, u)| u.len() <= MAX_URL).take(MAX_LINKS).cloned().collect();
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
|
||||
if !o.hyperlinks {
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -160,13 +160,18 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
|
||||
/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned.
|
||||
fn module_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
||||
if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
if path.is_dir() {
|
||||
// `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped,
|
||||
// not walked again as a new subtree.
|
||||
let mut paths: Vec<_> = entries.flatten().filter_map(|e| Some((e.path(), e.file_type().ok()?))).collect();
|
||||
paths.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (path, kind) in paths {
|
||||
if kind.is_dir() {
|
||||
module_files(&path, files);
|
||||
} else if path.extension().is_some_and(|x| x == "rs") {
|
||||
} else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
|
||||
enum Field {
|
||||
Num(&'static str, &'static str, f64),
|
||||
Bool(&'static str, &'static str, bool),
|
||||
Choice(&'static str, &'static str, String, std::sync::Arc<[String]>),
|
||||
Choice(&'static str, &'static str, String, Vec<String>),
|
||||
}
|
||||
|
||||
struct Style {
|
||||
@@ -371,8 +371,7 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
|
||||
no_drawing(ctx, "Multileader Style", open);
|
||||
return None;
|
||||
};
|
||||
// One list shared by every row: a copy per style multiplied the two counts.
|
||||
let text_styles: std::sync::Arc<[String]> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let text_styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let mut styles: Vec<Style> = d
|
||||
.mleader_styles
|
||||
.iter()
|
||||
@@ -383,7 +382,7 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
|
||||
Field::Num("textHeight", "Text height", s.text_height),
|
||||
Field::Num("landingGap", "Landing gap", s.landing_gap),
|
||||
Field::Num("dogleg", "Landing length", s.dogleg),
|
||||
Field::Choice("textStyle", "Text style", s.text_style.clone(), std::sync::Arc::clone(&text_styles)),
|
||||
Field::Choice("textStyle", "Text style", s.text_style.clone(), text_styles.clone()),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
@@ -452,7 +451,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
|
||||
ui.label(*label);
|
||||
let mut picked = None;
|
||||
craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
|
||||
for c in choices.iter() {
|
||||
for c in choices {
|
||||
if ui.selectable_label(c == v, c).clicked() && c != v {
|
||||
picked = Some(c.clone());
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
|
||||
let name = r.name.clone();
|
||||
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
|
||||
let op = |option: &str| json!({ "option": option, "names": name });
|
||||
let op = |option: &str| json!({ "option": option, "names": name, "exact": true });
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(8.0);
|
||||
if ui.button(crate::tl!("Reload")).clicked() {
|
||||
@@ -216,7 +216,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
|
||||
.clicked()
|
||||
{
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true })));
|
||||
}
|
||||
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
|
||||
*act = Some(Act::Run("xopen", json!({ "name": name })));
|
||||
@@ -230,7 +230,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
|
||||
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
|
||||
if (a || o) && overlay != r.overlay {
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay })));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -243,7 +243,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|
||||
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
|
||||
if apply {
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() })));
|
||||
ui.data_mut(|d| d.remove::<String>(path_id));
|
||||
} else if text != r.path {
|
||||
ui.data_mut(|d| d.insert_temp(path_id, text));
|
||||
|
||||
+14
-4
@@ -136,6 +136,18 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::{Counts, count_tests};
|
||||
|
||||
/// A new private directory in the temp folder. It is created, never reused: an existing path
|
||||
/// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes.
|
||||
fn fresh_dir(tag: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id()));
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b.create(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
@@ -147,8 +159,7 @@ mod tests {
|
||||
#[test]
|
||||
fn walk_does_not_follow_directory_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let base = fresh_dir("xtask");
|
||||
let real = base.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
@@ -162,8 +173,7 @@ mod tests {
|
||||
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
|
||||
#[test]
|
||||
fn walk_reports_budget_exhaustion() {
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let base = fresh_dir("stats-budget");
|
||||
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
|
||||
assert!(error.contains("budget exhausted"));
|
||||
|
||||
Reference in New Issue
Block a user