Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,25 @@ export const preprocessSelector = (
`Net name "${netName}" cannot start with a number, try using a prefix like "VBUS1"`,
)
}
return selector
.replace(/ pin(?=[\d.])/g, " port")
.replace(/ subcircuit\./g, " group[isSubcircuit=true]")
.replace(/([^ ])\>([^ ])/g, "$1 > $2")
.replace(
/(^|[ >])(?!pin\.)(?!port\.)(?!net\.)([A-Z][A-Za-z0-9_-]*)\.([A-Za-z0-9_-]+)/g,
(_, sep, name, pin) => {
const pinPart = /^\d+$/.test(pin) ? `pin${pin}` : pin
return `${sep}.${name} > .${pinPart}`
},
)
.trim()
return (
selector
.replace(/ pin(?=[\d.])/g, " port")
.replace(/ subcircuit\./g, " group[isSubcircuit=true]")
.replace(/([^ ])\>([^ ])/g, "$1 > $2")
.replace(
/(^|[ >])(?!pin\.)(?!port\.)(?!net\.)([A-Z][A-Za-z0-9_-]*)\.([A-Za-z0-9_+-]+)/g,
(_, sep, name, pin) => {
const pinPart = /^\d+$/.test(pin) ? `pin${pin}` : pin
return `${sep}.${name} > .${pinPart}`
},
)
// Escape "+" inside class/pin identifiers (e.g. a pin labelled "PUL+").
// css-select otherwise treats "+" as an adjacent-sibling combinator, so
// ".PUL+" would parse as ".PUL" followed by a combinator and never match a
// port literally named "PUL+". A leading "+" (e.g. ".+INA") already parses
// as part of the identifier, so we only escape "+" that follows an
// identifier character. Net names containing "+" are rejected above.
.replace(/(?<=[A-Za-z0-9_])\+/g, "\\+")
.trim()
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export function Trace__findConnectedPorts(trace: Trace):
let portToken: string
const dotIndex = selector.lastIndexOf(".")
if (dotIndex !== -1 && dotIndex > selector.lastIndexOf(" ")) {
parentSelector = selector.slice(0, dotIndex)
// Strip any trailing combinator (e.g. the "> " in ".U2 > .VIN+") so the
// parent selector resolves to the actual component instead of an empty
// or unrelated match, keeping the validation message accurate.
parentSelector = selector.slice(0, dotIndex).replace(/[\s>]+$/, "")
portToken = selector.slice(dotIndex + 1)
} else {
const match = selector.match(/^(.*[ >])?([^ >]+)$/)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { test, expect } from "bun:test"
import { preprocessSelector } from "lib/components/base-components/PrimitiveComponent/preprocessSelector"

test("preprocessSelector escapes '+' inside pin/class tokens", () => {
// A '+' following identifier characters would otherwise be treated by
// css-select as an adjacent-sibling combinator, so ".PUL+" could never match
// a port literally named "PUL+" (common on stepper drivers: PUL+, DIR+, ENA+).
expect(preprocessSelector(".DRIVER > .PUL+")).toBe(".DRIVER > .PUL\\+")

// Shorthand "Name.pin+" is normalized and the '+' is preserved + escaped.
expect(preprocessSelector("DRIVER.PUL+")).toBe(".DRIVER > .PUL\\+")
expect(preprocessSelector("SERVO2.V+")).toBe(".SERVO2 > .V\\+")

// A leading '+' already parses as part of the identifier, so it is left as-is.
expect(preprocessSelector(".U13 > .+INA")).toBe(".U13 > .+INA")

// '-' is a valid CSS identifier character and needs no escaping.
expect(preprocessSelector(".PUL-")).toBe(".PUL-")

// Selectors without '+' are untouched.
expect(preprocessSelector(".DRIVER > .PUL")).toBe(".DRIVER > .PUL")
expect(preprocessSelector("R1.pin1")).toBe(".R1 > .pin1")
})
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test("Chip not having name messes up the connections, uses the pin of the first
[
{
"error_type": "source_trace_not_connected_error",
"message": "Could not find port for selector ".unnamed_chip1 > .pin1". Component ".unnamed_chip1 > " not found",
"message": "Could not find port for selector ".unnamed_chip1 > .pin1". Component ".unnamed_chip1" not found",
"selectors_not_found": [
".unnamed_chip1 > .pin1",
],
Expand Down
42 changes: 42 additions & 0 deletions tests/repros/repro-plus-pin-selector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test, expect } from "bun:test"
import { getTestFixture } from "tests/fixtures/get-test-fixture"

// A trace pointing at a pin label containing "+" (e.g. a stepper driver's PUL+)
// used to fail with a misleading error: the parent selector was sliced with a
// dangling combinator (".U2 > ") so the message blamed an empty/unrelated
// component. It should instead name the real component and list its pins.
//
// Note: pinLabels containing "+"/"-" are excluded upstream by the
// @tscircuit/props schema (see filterPinLabels + the source_property_ignored
// warning), so the trace still cannot connect here — but the diagnostic must at
// least be accurate.
test("trace to a '+' pin reports an accurate not-connected error", () => {
const { circuit } = getTestFixture()

circuit.add(
<board width="20mm" height="20mm">
<chip
name="U2"
footprint="soic4"
pinLabels={{ pin1: "VINp", pin2: "GND" }}
/>
<net name="GND" />
<trace from=".U2 > .VIN+" to="net.GND" />
</board>,
)

circuit.render()

const error = circuit
.getCircuitJson()
.find((el) => el.type === "source_trace_not_connected_error") as
| { message: string }
| undefined

expect(error).toBeDefined()
// Names the actual component, not a dangling ".U2 > " or an unrelated one.
expect(error!.message).toContain('Component "U2" found')
expect(error!.message).not.toContain('Component ".U2')
// Lists the component's real pins rather than "It has no ports".
expect(error!.message).toContain("VINp")
})
Loading