master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… baad156 14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
//! Sheet operations on a project: add, edit, move, delete and duplicate a hierarchical
//! sheet, and add, edit, delete, import and validate its pins.
//!
//! A `Workspace` loads the project's whole sheet tree (root found through the `.kicad_pro`
//! beside the file, or the file itself when there is none) so a sheet added to a sub-sheet
//! gets one instance entry per use of that sub-sheet, page numbers continue after the
//! highest one in the design, and a duplicated sheet's parts get references nobody else
//! uses. Operations change documents in memory and queue new files; `Workspace::commit`
//! writes them (a `.bak` beside each changed file, new files never overwrite).

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::schematic::{fmt_num, is_symbol_instance, pretty, Node, Schematic};
use crate::sch_hier::{self as hier, Hierarchy, Paper, RefAllocator, Side, TitleEdit};
use crate::sch_model::{self, SheetRef};

pub struct Workspace {
    pub h: Hierarchy,
    /// The document being edited (index into `h.docs`).
    pub target: usize,
    pub file: PathBuf,
    /// Files to create: (path, text).
    pub new_files: Vec<(PathBuf, String)>,
    pub notes: Vec<String>,
}

#[derive(Debug)]
pub struct Written {
    pub changed: Vec<(PathBuf, PathBuf)>,
    pub created: Vec<PathBuf>,
}

fn snap(v: f64) -> f64 {
    (v / 1.27).round() * 1.27
}

fn r(v: f64) -> f64 {
    (v * 1e4).round() / 1e4 + 0.0
}

impl Workspace {
    /// Open `file` inside its project. `root` overrides the project root sheet.
    pub fn open(file: &Path, root: Option<&Path>) -> Result<Workspace, String> {
        let root_path = root.map(Path::to_path_buf).or_else(|| hier::project_root_for(file)).unwrap_or_else(|| file.to_path_buf());
        let mut notes = Vec::new();
        let mut h = Hierarchy::load(&root_path)?;
        let target = match h.doc_of(file) {
            Some(t) if h.paths_of(file).len() > 0 => t,
            _ => {
                if root.is_some() {
                    return Err(format!("{} is not a sheet of the hierarchy under {}", file.display(), root_path.display()));
                }
                notes.push(format!("{} is not reachable from the project root {}; it was edited as its own root.", file.display(), root_path.display()));
                h = Hierarchy::load(file)?;
                0
            }
        };
        Ok(Workspace { h, target, file: file.to_path_buf(), new_files: Vec::new(), notes })
    }

    pub fn sch(&self) -> &Schematic {
        &self.h.docs[self.target].1
    }
    pub fn sch_mut(&mut self) -> &mut Schematic {
        self.h.changed.insert(self.target);
        &mut self.h.docs[self.target].1
    }
    /// Instance paths of the edited file.
    pub fn paths(&self) -> Vec<String> {
        let p = self.h.paths_of(&self.file);
        if p.is_empty() { vec![format!("/{}", self.sch().root_uuid().unwrap_or_default())] } else { p }
    }

    /// Every file this commit would touch: (path, is new).
    pub fn plan(&self) -> Vec<(PathBuf, bool)> {
        let mut v: Vec<(PathBuf, bool)> = self.h.changed.iter().map(|&d| (self.h.docs[d].0.clone(), false)).collect();
        v.extend(self.new_files.iter().map(|(p, _)| (p.clone(), true)));
        v
    }

    pub fn commit(&self) -> Result<Written, String> {
        for (p, _) in &self.new_files {
            if p.exists() {
                return Err(format!("{} already exists; nothing was written", p.display()));
            }
        }
        let mut created = Vec::new();
        for (p, text) in &self.new_files {
            if let Some(d) = p.parent() {
                std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
            }
            crate::libraries::write_atomic(p, text.as_bytes())?;
            created.push(p.clone());
        }
        let mut changed = Vec::new();
        for &d in &self.h.changed {
            let (p, s) = &self.h.docs[d];
            let bak = s.save_with_backup(p)?;
            changed.push((p.clone(), bak));
        }
        Ok(Written { changed, created })
    }

    fn sheets(&self) -> Vec<SheetRef> {
        self.sch().doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect()
    }

    fn sheet_index(&self, key: &str) -> Result<usize, String> {
        hier::find_sheet(self.sch(), key)
    }

    fn sheet_at(&self, idx: usize) -> SheetRef {
        sch_model::sheet_ref(&self.sch().doc.root.children()[idx])
    }

    fn alloc(&self) -> RefAllocator {
        RefAllocator { used: self.h.references() }
    }

    /// A free spot right of the sheets already on this page.
    fn free_spot(&self, size: (f64, f64)) -> (f64, f64) {
        let sheets = self.sheets();
        if sheets.is_empty() {
            return (25.4, 25.4);
        }
        let right = sheets.iter().map(|s| s.at.0 + s.size.0).fold(0.0, f64::max);
        let top = sheets.iter().map(|s| s.at.1).fold(f64::MAX, f64::min);
        let paper = Paper::of(self.sch()).size_mm();
        let x = snap(right + 12.7);
        if x + size.0 < paper.0 - 10.0 {
            (r(x), r(snap(top)))
        } else {
            let bottom = sheets.iter().map(|s| s.at.1 + s.size.1).fold(0.0, f64::max);
            (25.4, r(snap(bottom + 12.7)))
        }
    }

    // -----------------------------------------------------------------------
    // sheets
    // -----------------------------------------------------------------------

    pub fn add_sheet(&mut self, name: &str, file: &str, at: Option<(f64, f64)>, size: Option<(f64, f64)>, import_pins: bool) -> Result<Value, String> {
        let name = name.trim();
        if name.is_empty() {
            return Err("a sheet needs a name".into());
        }
        if self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
            return Err(format!("this sheet already has a sheet named {name}; sheet names must be unique on a page"));
        }
        let file = file.trim().replace('\\', "/");
        if !file.to_ascii_lowercase().ends_with(".kicad_sch") {
            return Err(format!("file {file} must end in .kicad_sch"));
        }
        let child = hier::resolve_child(&self.file, &file);
        let ancestors: Vec<PathBuf> = self.ancestor_files();
        if ancestors.iter().any(|a| same_file(a, &child)) {
            return Err(format!("{file} is this sheet or one of its parents: a sheet cannot contain itself"));
        }
        let size = size.unwrap_or((25.4, 20.32));
        let at = at.map(|(x, y)| (r(snap(x)), r(snap(y)))).unwrap_or_else(|| self.free_spot(size));
        let project = self.h.project_name();
        let parent_paths = self.paths();
        let mut page = self.h.max_page();
        let pages: Vec<(String, String)> = parent_paths
            .iter()
            .map(|p| {
                page += 1;
                (p.clone(), page.to_string())
            })
            .collect();
        let (mut node, uuid) = hier::sheet_node(name, &file, at, size, &project, &pages);

        let mut created = false;
        let mut labels = Vec::new();
        let mut renames = Vec::new();
        if child.is_file() {
            let mut cs = Schematic::load(&child)?;
            labels = hier::hier_labels(&cs);
            // symbols in a linked file need an instance entry for each new use
            let mut alloc = self.alloc();
            let mut changed = false;
            for (p, _) in &pages {
                let new_path = format!("{p}/{uuid}");
                let rn = ensure_symbol_instances(&mut cs, &new_path, None, &project, &mut alloc);
                changed |= !rn.is_empty() || cs.doc.root.children().iter().any(|n| is_symbol_instance(n));
                renames.extend(rn);
            }
            if changed {
                let d = match self.h.doc_of(&child) {
                    Some(d) => {
                        self.h.docs[d].1 = cs;
                        d
                    }
                    None => {
                        self.h.docs.push((child.clone(), cs));
                        self.h.docs.len() - 1
                    }
                };
                self.h.changed.insert(d);
            }
        } else {
            let paper = Paper::of(self.sch());
            let (text, _) = hier::blank_sheet(&paper, &TitleEdit { title: Some(name.to_string()), ..Default::default() });
            self.new_files.push((child.clone(), text));
            created = true;
        }
        let mut pins = Vec::new();
        if import_pins && !labels.is_empty() {
            let mut sref = sch_model::sheet_ref(&node);
            for (lname, shape, _) in &labels {
                let side = hier::default_side(shape);
                let spot = hier::pin_spot(&sref, side, None)?;
                let (pin, puuid) = hier::sheet_pin_node(lname, shape, spot, side);
                hier::push_sheet_pin(&mut node, pin);
                sref = sch_model::sheet_ref(&node);
                pins.push(json!({"name": lname, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": puuid}));
            }
        }
        hier::insert_top(self.sch_mut(), node);
        Ok(json!({
            "uuid": uuid,
            "name": name,
            "file": file,
            "childPath": child.to_string_lossy().replace('\\', "/"),
            "childCreated": created,
            "at": [at.0, at.1],
            "size": [size.0, size.1],
            "pages": pages.iter().map(|(p, pg)| json!({"path": format!("{p}/{uuid}"), "page": pg})).collect::<Vec<_>>(),
            "pinsAdded": pins,
            "hierarchicalLabelsInChild": labels.len(),
            "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>(),
        }))
    }

    fn ancestor_files(&self) -> Vec<PathBuf> {
        let mut out = vec![self.file.clone()];
        for (i, inst) in self.h.instances.iter().enumerate() {
            if inst.doc.is_some() && same_file(&inst.file, &self.file) {
                let mut p = inst.parent;
                while let Some(k) = p {
                    out.push(self.h.instances[k].file.clone());
                    p = self.h.instances[k].parent;
                }
                let _ = i;
            }
        }
        out
    }

    pub fn edit_sheet(&mut self, key: &str, new_name: Option<&str>, file: Option<&str>, size: Option<(f64, f64)>, at: Option<(f64, f64)>) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let before = self.sheet_at(idx);
        let mut changes = Vec::new();
        if let Some(n) = new_name.map(str::trim).filter(|n| !n.is_empty()) {
            if n != before.name && self.sheets().iter().any(|s| s.uuid != before.uuid && s.name.eq_ignore_ascii_case(n)) {
                return Err(format!("another sheet on this page is already named {n}"));
            }
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
            hier::set_sheet_field(node, "Sheetname", n);
            changes.push(format!("name {} -> {n}", before.name));
        }
        let mut child_created = false;
        if let Some(f) = file.map(|f| f.trim().replace('\\', "/")).filter(|f| !f.is_empty()) {
            if !f.to_ascii_lowercase().ends_with(".kicad_sch") {
                return Err(format!("file {f} must end in .kicad_sch"));
            }
            let child = hier::resolve_child(&self.file, &f);
            if self.ancestor_files().iter().any(|a| same_file(a, &child)) {
                return Err(format!("{f} is this sheet or one of its parents: a sheet cannot contain itself"));
            }
            if !child.is_file() {
                let paper = Paper::of(self.sch());
                let (text, _) = hier::blank_sheet(&paper, &TitleEdit { title: Some(before.name.clone()), ..Default::default() });
                self.new_files.push((child.clone(), text));
                child_created = true;
            }
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
            hier::set_sheet_field(node, "Sheetfile", &f);
            changes.push(format!("file {} -> {f}{}", before.file, if child_created { " (created blank)" } else { "" }));
        }
        if let Some((w, h)) = size {
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
            hier::resize_sheet_node(node, r(w), r(h))?;
            changes.push(format!("size {}x{} -> {}x{}", fmt_num(before.size.0), fmt_num(before.size.1), fmt_num(r(w)), fmt_num(r(h))));
        }
        if let Some((x, y)) = at {
            let cur = self.sheet_at(idx).at;
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
            hier::move_sheet_node(node, x - cur.0, y - cur.1);
            changes.push(format!("at ({}, {}) -> ({}, {})", fmt_num(cur.0), fmt_num(cur.1), fmt_num(x), fmt_num(y)));
        }
        if changes.is_empty() {
            return Err("nothing to change: pass newName, file, size or at".into());
        }
        let after = self.sheet_at(idx);
        Ok(json!({"uuid": after.uuid, "name": after.name, "file": after.file, "at": [after.at.0, after.at.1], "size": [after.size.0, after.size.1], "changes": changes, "childCreated": child_created}))
    }

    pub fn move_sheet(&mut self, key: &str, to: Option<(f64, f64)>, by: Option<(f64, f64)>) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let s = self.sheet_at(idx);
        let (dx, dy) = match (to, by) {
            (Some(t), _) => (t.0 - s.at.0, t.1 - s.at.1),
            (None, Some(b)) => b,
            _ => return Err("pass to:[x, y] (new top-left corner) or by:[dx, dy]".into()),
        };
        let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
        hier::move_sheet_node(node, dx, dy);
        let after = self.sheet_at(idx);
        Ok(json!({"uuid": s.uuid, "name": s.name, "from": [s.at.0, s.at.1], "to": [after.at.0, after.at.1], "pinsMoved": s.pins.len(), "note": "Wires attached to the sheet pins do not follow; redraw them or run kicad_sch_check to find the ends left behind."}))
    }

    pub fn delete_sheet(&mut self, key: &str) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let s = self.sheet_at(idx);
        self.sch_mut().doc.root.children_mut().expect("list").remove(idx);
        let child = hier::resolve_child(&self.file, &s.file);
        let still_used = self.h.instances.iter().filter(|i| i.sheet_uuid.as_deref() != Some(s.uuid.as_str()) && i.doc.is_some() && same_file(&i.file, &child)).count();
        Ok(json!({
            "uuid": s.uuid,
            "name": s.name,
            "file": s.file,
            "childFileKept": child.to_string_lossy().replace('\\', "/"),
            "childStillUsedElsewhere": still_used,
            "pinsRemoved": s.pins.len(),
        }))
    }

    pub fn duplicate_sheet(&mut self, key: &str, new_name: Option<&str>, file: Option<&str>, at: Option<(f64, f64)>) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let src = self.sheet_at(idx);
        let src_child = hier::resolve_child(&self.file, &src.file);
        if !src_child.is_file() {
            return Err(format!("the sheet's file {} does not exist; nothing to duplicate", src.file));
        }
        let name = match new_name.map(str::trim).filter(|n| !n.is_empty()) {
            Some(n) => n.to_string(),
            None => {
                let mut k = 2;
                loop {
                    let cand = format!("{}{k}", src.name);
                    if !self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(&cand)) {
                        break cand;
                    }
                    k += 1;
                }
            }
        };
        if self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(&name)) {
            return Err(format!("this page already has a sheet named {name}"));
        }
        let dir = src_child.parent().unwrap_or(Path::new(".")).to_path_buf();
        let stem = src_child.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "sheet".into());
        let file_rel = match file.map(|f| f.trim().replace('\\', "/")).filter(|f| !f.is_empty()) {
            Some(f) => f,
            None => {
                let fname = hier::free_copy_name(&dir, &stem);
                let parent_dir = self.file.parent().unwrap_or(Path::new("."));
                match dir.strip_prefix(parent_dir) {
                    Ok(rel) if rel.as_os_str().is_empty() => fname,
                    Ok(rel) => format!("{}/{fname}", rel.to_string_lossy().replace('\\', "/")),
                    Err(_) => dir.join(&fname).to_string_lossy().replace('\\', "/"),
                }
            }
        };
        let new_child = hier::resolve_child(&self.file, &file_rel);
        if new_child.exists() || self.new_files.iter().any(|(p, _)| same_file(p, &new_child)) {
            return Err(format!("{file_rel} already exists; pass another file name"));
        }
        let size = src.size;
        let at = at.unwrap_or((src.at.0 + src.size.0 + 12.7, src.at.1));
        let at = (r(snap(at.0)), r(snap(at.1)));
        let project = self.h.project_name();
        let parent_paths = self.paths();
        let mut page = self.h.max_page();
        let pages: Vec<(String, String)> = parent_paths
            .iter()
            .map(|p| {
                page += 1;
                (p.clone(), page.to_string())
            })
            .collect();
        let (mut node, uuid) = hier::sheet_node(&name, &file_rel, at, size, &project, &pages);
        // same pins as the source, same shapes, same edge offsets
        for p in &src.pins {
            let side = Side::of(&src, p.at);
            let spot = (r(p.at.0 - src.at.0 + at.0), r(p.at.1 - src.at.1 + at.1));
            let (pin, _) = hier::sheet_pin_node(&p.name, &p.shape, spot, side);
            hier::push_sheet_pin(&mut node, pin);
        }
        // the copy: fresh uuids, its own instance entries, references nobody else uses
        let mut copy = Schematic::load(&src_child)?;
        let old_path = format!("{}/{}", parent_paths[0], src.uuid);
        hier::refresh_uuids(&mut copy);
        let mut alloc = self.alloc();
        let mut renames = Vec::new();
        for (i, (p, _)) in pages.iter().enumerate() {
            let new_path = format!("{p}/{uuid}");
            if i == 0 {
                strip_symbol_instances(&mut copy);
            }
            renames.extend(ensure_symbol_instances(&mut copy, &new_path, Some(&old_path), &project, &mut alloc));
        }
        // sheets nested in the copy point at the same child files: those files gain instances
        let nested: Vec<SheetRef> = copy.doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect();
        let mut nested_note = Vec::new();
        if !nested.is_empty() {
            for (p, _) in &pages {
                let copy_path = format!("{p}/{uuid}");
                hier::retarget_sheet_instances(&mut copy, &copy_path, &project, &mut page);
            }
            for ns in &nested {
                let gfile = hier::resolve_child(&new_child, &ns.file);
                nested_note.push(format!("{} ({}) is shared with the original, not copied", ns.name, ns.file));
                if let Some(d) = self.h.doc_of(&gfile) {
                    for (p, _) in &pages {
                        let gpath = format!("{p}/{uuid}/{}", ns.uuid);
                        let rn = ensure_symbol_instances(&mut self.h.docs[d].1, &gpath, None, &project, &mut alloc);
                        renames.extend(rn);
                    }
                    self.h.changed.insert(d);
                }
            }
        }
        self.new_files.push((new_child.clone(), copy.to_text()));
        hier::insert_top(self.sch_mut(), node);
        renames.sort();
        renames.dedup();
        Ok(json!({
            "uuid": uuid,
            "name": name,
            "file": file_rel,
            "childPath": new_child.to_string_lossy().replace('\\', "/"),
            "from": {"uuid": src.uuid, "name": src.name, "file": src.file},
            "at": [at.0, at.1],
            "size": [size.0, size.1],
            "pages": pages.iter().map(|(p, pg)| json!({"path": format!("{p}/{uuid}"), "page": pg})).collect::<Vec<_>>(),
            "pinsCopied": src.pins.len(),
            "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>(),
            "sharedNestedSheets": nested_note,
        }))
    }

    // -----------------------------------------------------------------------
    // sheet pins
    // -----------------------------------------------------------------------

    fn child_labels(&self, s: &SheetRef) -> Result<Vec<(String, String, String)>, String> {
        let child = hier::resolve_child(&self.file, &s.file);
        if let Some(d) = self.h.doc_of(&child) {
            return Ok(hier::hier_labels(&self.h.docs[d].1));
        }
        if let Some((_, text)) = self.new_files.iter().find(|(p, _)| same_file(p, &child)) {
            return Ok(hier::hier_labels(&Schematic::parse(text)?));
        }
        if !child.is_file() {
            return Err(format!("the sheet's file {} does not exist", s.file));
        }
        Ok(hier::hier_labels(&Schematic::load(&child)?))
    }

    pub fn add_pin(&mut self, key: &str, name: &str, shape: &str, side: Option<Side>, at: Option<(f64, f64)>) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let s = self.sheet_at(idx);
        let name = name.trim();
        if name.is_empty() {
            return Err("a sheet pin needs a name (the hierarchical label it meets inside)".into());
        }
        let shape = shape.trim().to_ascii_lowercase();
        if !hier::PIN_SHAPES.contains(&shape.as_str()) {
            return Err(format!("shape {shape} is not one of {}", hier::PIN_SHAPES.join(", ")));
        }
        if s.pins.iter().any(|p| p.name == name) {
            return Err(format!("sheet {} already has a pin named {name}", s.name));
        }
        let side = side.or_else(|| at.map(|p| Side::of(&s, p))).unwrap_or_else(|| hier::default_side(&shape));
        let spot = hier::pin_spot(&s, side, at)?;
        let (pin, uuid) = hier::sheet_pin_node(name, &shape, spot, side);
        let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
        hier::push_sheet_pin(node, pin);
        let has_label = self.child_labels(&s).map(|l| l.iter().any(|(n, _, _)| n == name)).unwrap_or(false);
        Ok(json!({"sheet": s.name, "name": name, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": uuid, "childHasLabel": has_label}))
    }

    fn pin_index(&self, sheet_idx: usize, pin: &str) -> Result<usize, String> {
        let node = &self.sch().doc.root.children()[sheet_idx];
        let hits: Vec<usize> = node.children().iter().enumerate().filter(|(_, c)| c.head() == Some("pin") && (c.arg(0).as_deref() == Some(pin) || c.child_value("uuid").as_deref() == Some(pin))).map(|(i, _)| i).collect();
        match hits.len() {
            1 => Ok(hits[0]),
            0 => Err(format!("sheet has no pin {pin} (pins: {})", node.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect::<Vec<_>>().join(", "))),
            _ => Err(format!("{} pins are named {pin}; pass the pin's uuid", hits.len())),
        }
    }

    pub fn edit_pin(&mut self, key: &str, pin: &str, new_name: Option<&str>, shape: Option<&str>, side: Option<Side>, at: Option<(f64, f64)>) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let s = self.sheet_at(idx);
        let pi = self.pin_index(idx, pin)?;
        let mut changes = Vec::new();
        let old = s.pins.iter().find(|p| p.name == pin || p.uuid == pin).cloned().ok_or("pin vanished")?;
        if let Some(n) = new_name.map(str::trim).filter(|n| !n.is_empty() && *n != old.name) {
            if s.pins.iter().any(|p| p.name == n) {
                return Err(format!("sheet {} already has a pin named {n}", s.name));
            }
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];
            node.set_arg(0, Node::quoted(n));
            changes.push(format!("name {} -> {n}", old.name));
        }
        if let Some(sh) = shape.map(|x| x.trim().to_ascii_lowercase()) {
            if !hier::PIN_SHAPES.contains(&sh.as_str()) {
                return Err(format!("shape {sh} is not one of {}", hier::PIN_SHAPES.join(", ")));
            }
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];
            node.set_arg(1, Node::atom(sh.clone()));
            changes.push(format!("shape {} -> {sh}", old.shape));
        }
        if side.is_some() || at.is_some() {
            let mut others = s.clone();
            others.pins.retain(|p| p.uuid != old.uuid);
            let side = side.or_else(|| at.map(|p| Side::of(&s, p))).unwrap_or_else(|| Side::of(&s, old.at));
            let spot = hier::pin_spot(&others, side, at)?;
            let (angle, _) = match side {
                Side::Left => (180.0, ""),
                Side::Right => (0.0, ""),
                Side::Top => (90.0, ""),
                Side::Bottom => (270.0, ""),
            };
            let justify = match side {
                Side::Left | Side::Bottom => "left",
                _ => "right",
            };
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];
            if let Some(a) = node.child_mut("at") {
                a.set_arg(0, Node::number(spot.0));
                a.set_arg(1, Node::number(spot.1));
                a.set_arg(2, Node::number(angle));
            }
            if let Some(j) = node.child_mut("effects").and_then(|e| e.child_mut("justify")) {
                j.set_arg(0, Node::atom(justify));
            }
            changes.push(format!("moved to the {} edge at ({}, {})", side.name(), fmt_num(spot.0), fmt_num(spot.1)));
        }
        if changes.is_empty() {
            return Err("nothing to change: pass newName, shape, side or at".into());
        }
        Ok(json!({"sheet": s.name, "pin": old.name, "uuid": old.uuid, "changes": changes}))
    }

    pub fn delete_pin(&mut self, key: &str, pin: &str) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let s = self.sheet_at(idx);
        let pi = self.pin_index(idx, pin)?;
        let removed = self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list").remove(pi);
        Ok(json!({"sheet": s.name, "name": removed.arg(0), "uuid": removed.child_value("uuid"), "note": "A wire that ended on the pin now dangles; kicad_sch_check lists it."}))
    }

    /// Pins for every hierarchical label in the child that the sheet lacks.
    pub fn import_pins(&mut self, key: &str) -> Result<Value, String> {
        let idx = self.sheet_index(key)?;
        let mut s = self.sheet_at(idx);
        let labels = self.child_labels(&s)?;
        let mut added = Vec::new();
        let missing: Vec<(String, String)> = labels.iter().filter(|(n, _, _)| !s.pins.iter().any(|p| p.name == *n)).map(|(n, sh, _)| (n.clone(), sh.clone())).collect();
        for (name, shape) in &missing {
            let side = hier::default_side(shape);
            let spot = hier::pin_spot(&s, side, None)?;
            let (pin, uuid) = hier::sheet_pin_node(name, shape, spot, side);
            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];
            hier::push_sheet_pin(node, pin);
            s = self.sheet_at(idx);
            added.push(json!({"name": name, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": uuid}));
        }
        Ok(json!({"sheet": s.name, "labelsInChild": labels.len(), "added": added, "alreadyPresent": labels.len() - added.len()}))
    }

    pub fn validate_pins(&self, key: Option<&str>) -> Result<Value, String> {
        let sheets: Vec<SheetRef> = match key {
            Some(k) => vec![self.sheet_at(self.sheet_index(k)?)],
            None => self.sheets(),
        };
        let mut out = Vec::new();
        let mut problems = 0;
        for s in &sheets {
            match self.child_labels(s) {
                Ok(labels) => {
                    let (no_pin, no_label, mismatch) = hier::pin_label_diff(s, &labels);
                    problems += no_pin.len() + no_label.len() + mismatch.len();
                    out.push(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "ok": no_pin.is_empty() && no_label.is_empty() && mismatch.is_empty(), "labelsWithoutPin": no_pin, "pinsWithoutLabel": no_label, "shapeMismatch": mismatch}));
                }
                Err(e) => {
                    problems += 1;
                    out.push(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "ok": false, "error": e}));
                }
            }
        }
        Ok(json!({"ok": problems == 0, "problems": problems, "sheets": out}))
    }

    pub fn list_pins(&self, key: &str) -> Result<Value, String> {
        let s = self.sheet_at(self.sheet_index(key)?);
        Ok(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "at": [s.at.0, s.at.1], "size": [s.size.0, s.size.1], "pins": s.pins.iter().map(|p| json!({"name": p.name, "shape": p.shape, "side": Side::of(&s, p.at).name(), "at": [p.at.0, p.at.1], "uuid": p.uuid})).collect::<Vec<_>>()}))
    }
}

fn same_file(a: &Path, b: &Path) -> bool {
    let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());
    let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());
    ca == cb
}

/// Remove every symbol's instances block (a copied file starts clean).
pub fn strip_symbol_instances(sch: &mut Schematic) {
    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| is_symbol_instance(n)) {
        let c = n.children_mut().expect("list");
        if let Some(i) = c.iter().position(|x| x.head() == Some("instances")) {
            c.remove(i);
        }
    }
}

/// Give every symbol an instance entry for `path` when it lacks one. The reference is the one
/// it has under `from` (else its Reference field); a reference already used in the design is
/// replaced by the next free one (units of one part stay together). Returns the renames.
pub fn ensure_symbol_instances(sch: &mut Schematic, path: &str, from: Option<&str>, project: &str, alloc: &mut RefAllocator) -> Vec<(String, String)> {
    let mut renames = Vec::new();
    let mut chosen: std::collections::HashMap<String, String> = Default::default();
    let mut claimed: BTreeSet<String> = BTreeSet::new();
    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| is_symbol_instance(n)) {
        let has = n.child("instances").map_or(false, |i| i.children().iter().any(|p| p.head() == Some("project") && p.children().iter().any(|q| q.head() == Some("path") && q.arg(0).as_deref() == Some(path))));
        if has {
            continue;
        }
        let base = sch_model::instance_reference(n, from);
        let reference = if base.starts_with('#') || base.ends_with('?') {
            base.clone()
        } else if let Some(r) = chosen.get(&base) {
            r.clone()
        } else {
            let r = if alloc.used.contains(&base) && !claimed.contains(&base) { alloc.next(&base) } else {
                alloc.used.insert(base.clone());
                base.clone()
            };
            claimed.insert(r.clone());
            chosen.insert(base.clone(), r.clone());
            r
        };
        if reference != base {
            renames.push((base.clone(), reference.clone()));
        }
        let unit = n.child("unit").and_then(|u| u.arg(0)).unwrap_or_else(|| "1".into());
        let entry = Node::list("path", vec![Node::quoted(path), Node::list("reference", vec![Node::quoted(&reference)]), Node::list("unit", vec![Node::atom(unit)])]);
        let children = n.children_mut().expect("list");
        match children.iter_mut().find(|c| c.head() == Some("instances")) {
            Some(insts) => {
                let ic = insts.children_mut().expect("list");
                if let Some(proj) = ic.iter_mut().find(|p| p.head() == Some("project")) {
                    let mut e = entry;
                    pretty(&mut e, 4);
                    proj.children_mut().expect("list").push(e);
                } else {
                    let mut proj = Node::list("project", vec![Node::quoted(project), entry]);
                    pretty(&mut proj, 3);
                    ic.push(proj);
                }
            }
            None => {
                let mut inst = Node::list("instances", vec![Node::list("project", vec![Node::quoted(project), entry])]);
                pretty(&mut inst, 2);
                children.push(inst);
            }
        }
        // a symbol used once shows its instance reference in the Reference field too
        if let Some(p) = children.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")) {
            if p.arg(1).as_deref() != Some(reference.as_str()) && from.is_some() {
                p.set_arg(1, Node::quoted(&reference));
            }
        }
    }
    renames.sort();
    renames.dedup();
    renames
}