diff --git a/.changeset/warm-planes-refactor.md b/.changeset/warm-planes-refactor.md new file mode 100644 index 0000000..1afeb23 --- /dev/null +++ b/.changeset/warm-planes-refactor.md @@ -0,0 +1,18 @@ +--- +"@openproject/stimulus-elements": minor +--- + +Fix `WithElements` to camelize keys like the runtime, and warn on colliding element keys + +- `WithElements<{ menu_item: string }>` now yields `menuItemElement` / + `menuItemElements` / `hasMenuItemElement`, matching the runtime accessors. + Previously it produced the wrong `menu_itemElement` names; if you worked + around this by passing pre-camelized keys, those still work — but usages + typed against the old snake_case accessor names must be renamed. +- Element keys whose generated accessor names collide (e.g. `foo` and `_foo` + both produce `hasFooElement`, or `foo`'s predicate vs `hasFoo`'s getter) + now emit a console warning naming both keys; the later definition wins that + property, as before. +- The naming rule (key → accessor triple + attribute suffix) now lives in one + module, with the acronym behaviour (`htmlURL` → `html-u-r-l`) locked in by + tests. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ab69c5..bad2374 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,4 +17,5 @@ jobs: - run: bun install --frozen-lockfile - run: bunx playwright install chromium --with-deps - run: bun run test + - run: bun run typecheck - run: bun run build diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b2cd965 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,45 @@ +# Domain glossary + +Vocabulary for this library. Use these terms in code, tests, and reviews. + +## Terms + +**Element definition** +The mapping from one `static elements` key to its accessor triple and +attribute-override suffix. Owned by `src/element-definition.ts` — the single +place the naming rule lives, at runtime (`elementDefinition`) and at the type +level (`Camelize`, `WithElements`). + +**Accessor triple** +The three generated properties per element definition: `xElement` +(`Element | null`), `xElements` (`Element[]`), `hasXElement` (`boolean`), +where `x` is the camelized key. + +**Attribute override** +The per-instance selector override read from the controller element: +`data-{identifier}-{suffix}-element`, where `{suffix}` is the dasherized +element name. Non-empty values win over the static selector; read live. + +**Blessing** +Stimulus's mechanism for extending controllers at registration time. +`ElementsBlessing` (`src/blessing.ts`) turns `static elements` into the +accessor triples via element definitions. + +## Recorded decisions + +- **Acronym lock-in.** Dasherization is naive and Stimulus-compatible: + `htmlURL` → suffix `html-u-r-l`. Locked in by tests; not to be "fixed". +- **Collision policy.** The three generated names share one property + namespace. A clash between names from *different* raw keys (e.g. `foo` vs + `_foo` on `hasFooElement`, or `foo` vs `hasFoo` across kinds) warns and the + later definition wins that property. Same-raw-key redefinition (subclass + overriding a parent selector) stays silent. Nothing throws. +- **Type level unions colliding roles.** `WithElements` cannot model + property-level last-wins (runtime key order decides), so a colliding name + gets the union of every role that generates it (e.g. `hasFooElement: + boolean | Element | null`), forcing callers to narrow. An intersection was + rejected: it is silently assignable to *both* roles on reads, hiding the + pathology instead of surfacing it. +- **Type/runtime lockstep.** `Camelize` mirrors the runtime `camelize` + regex exactly (ASCII-only, tail-recursive). Twin sample tables live in + `test/element-definition.test.ts` and `test/types.test-d.ts` — keep in sync. diff --git a/README.md b/README.md index 698a15e..aa8e732 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ class MyController extends Controller { } ``` -`WithElements` keys must already be camelCase (matching the generated accessor -names) — e.g. use `menuItem`, not `menu_item`, in the type argument even though -the runtime `static elements` key may be `menu_item`. +`WithElements` camelizes keys exactly like the runtime, so you can pass your +`static elements` keys verbatim — `menu_item` and `menuItem` both yield +`menuItemElement` / `menuItemElements` / `hasMenuItemElement`. ## Releasing diff --git a/index.ts b/index.ts index 5565693..5355ba7 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,3 @@ export { installElements } from "./src/install" export { ElementsBlessing } from "./src/blessing" -export type { WithElements } from "./src/types" +export type { WithElements } from "./src/element-definition" diff --git a/package.json b/package.json index b68a256..134468e 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,13 @@ "files": ["dist", "README.md"], "sideEffects": false, "scripts": { - "build": "bun build ./index.ts --outdir dist --format esm --external @hotwired/stimulus && tsc -p tsconfig.build.json", + "build": "rm -rf dist && bun build ./index.ts --outdir dist --format esm --external @hotwired/stimulus && tsc -p tsconfig.build.json", "changeset": "changeset", "changeset:version": "changeset version", "prepublishOnly": "bun run build", "release": "bun run build && changeset publish", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "keywords": ["stimulus", "hotwired", "elements", "blessing", "dom"], "license": "MIT", diff --git a/src/blessing.ts b/src/blessing.ts index e0db03e..07ce359 100644 --- a/src/blessing.ts +++ b/src/blessing.ts @@ -1,4 +1,5 @@ -import { camelize, capitalize, dasherize, readInheritableStaticObjectPairs } from "./helpers" +import { readInheritableStaticObjectPairs } from "./helpers" +import { mergeElementDefinitions, type ElementDefinition } from "./element-definition" import { queryOne, queryAll } from "./query" interface ElementScope { @@ -7,14 +8,11 @@ interface ElementScope { } export function ElementsBlessing(constructor: unknown): PropertyDescriptorMap { - const merged = new Map() - for (const [key, selector] of readInheritableStaticObjectPairs(constructor, "elements")) { - merged.set(key, selector) // later-wins → subclass overrides - } + const pairs = readInheritableStaticObjectPairs(constructor, "elements") const properties: PropertyDescriptorMap = {} - for (const [key, selector] of merged) { - Object.assign(properties, propertiesForElementDefinition(camelize(key), selector)) + for (const { definition, selector } of mergeElementDefinitions(pairs)) { + Object.assign(properties, propertiesForElementDefinition(definition, selector)) } return properties } @@ -25,20 +23,23 @@ function resolveSelector(scope: ElementScope, attrSuffix: string, staticSelector return staticSelector } -function propertiesForElementDefinition(name: string, selector: string): PropertyDescriptorMap { - const attrSuffix = dasherize(name) +function propertiesForElementDefinition( + def: ElementDefinition, + selector: string, +): PropertyDescriptorMap { + const attrSuffix = def.attributeSuffix return { - [`${name}Element`]: { + [def.getterName]: { get(this: ElementScope): Element | null { return queryOne(this.element, resolveSelector(this, attrSuffix, selector)) }, }, - [`${name}Elements`]: { + [def.pluralName]: { get(this: ElementScope): Element[] { return queryAll(this.element, resolveSelector(this, attrSuffix, selector)) }, }, - [`has${capitalize(name)}Element`]: { + [def.predicateName]: { get(this: ElementScope): boolean { return queryOne(this.element, resolveSelector(this, attrSuffix, selector)) !== null }, diff --git a/src/element-definition.ts b/src/element-definition.ts new file mode 100644 index 0000000..6b07ac5 --- /dev/null +++ b/src/element-definition.ts @@ -0,0 +1,124 @@ +// Single owner of the element-naming rule: one `static elements` key maps to +// the accessor triple (`xElement` / `xElements` / `hasXElement`) and the +// attribute-override suffix (`data-{identifier}-{suffix}-element`). +// +// Acronym keys keep Stimulus's naive dasherization on purpose: +// `htmlURL` → suffix `html-u-r-l`. + +export interface ElementDefinition { + readonly getterName: string + readonly pluralName: string + readonly predicateName: string + readonly attributeSuffix: string +} + +export function elementDefinition(key: string): ElementDefinition { + const name = camelize(key) + return { + getterName: `${name}Element`, + pluralName: `${name}Elements`, + predicateName: `has${capitalize(name)}Element`, + attributeSuffix: dasherize(name), + } +} + +// Merge raw `static elements` pairs into definitions: +// - same raw key: later-wins silently (subclass overrides parent selector) +// - different raw keys claiming the same generated property name: warn; +// the later descriptor wins that property (Object.assign semantics). +// The three generated names share one property namespace, so `foo`/`_foo` +// collide on the predicate and `foo`/`hasFoo` collide across kinds. +export function mergeElementDefinitions( + pairs: [string, string][], +): { definition: ElementDefinition; selector: string }[] { + const byRawKey = new Map() + for (const [key, selector] of pairs) { + byRawKey.set(key, selector) // later-wins → subclass overrides + } + + const claimedBy = new Map() + const merged: { definition: ElementDefinition; selector: string }[] = [] + for (const [key, selector] of byRawKey) { + const definition = elementDefinition(key) + for (const property of [ + definition.getterName, + definition.pluralName, + definition.predicateName, + ]) { + const incumbent = claimedBy.get(property) + if (incumbent !== undefined && incumbent !== key) { + console.warn( + `[stimulus-elements] Element keys ${JSON.stringify(incumbent)} and ${JSON.stringify(key)} ` + + `both define property ${JSON.stringify(property)}; using ${JSON.stringify(selector)} from ${JSON.stringify(key)}`, + ) + } + claimedBy.set(property, key) + } + merged.push({ definition, selector }) + } + return merged +} + +function camelize(value: string): string { + return value.replace(/[-_]([a-z0-9])/gi, (_match, char: string) => char.toUpperCase()) +} + +function capitalize(value: string): string { + return value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1) +} + +function dasherize(value: string): string { + return value.replace(/([A-Z])/g, (_match, char: string) => `-${char.toLowerCase()}`) +} + +type Separator = "-" | "_" +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" +type LowerAlpha = + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" + | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" +// Mirrors the runtime regex char class [a-z0-9] with the `i` flag: ASCII only. +type Camelizable = Digit | LowerAlpha | Uppercase + +// Tail-recursive (accumulator) form — the nested form hits TS2589 on long keys. +type CamelizeImpl = + S extends `${infer Head}${infer Tail}` + ? Head extends Separator + ? Tail extends `${infer Next}${infer Rest}` + ? Next extends Camelizable + ? CamelizeImpl}`> + : CamelizeImpl + : `${Acc}${Head}` + : CamelizeImpl + : `${Acc}${S}` + +// Type-level twin of `camelize` — must stay in lockstep with the regex above. +export type Camelize = CamelizeImpl + +// Numeric keys are stringified like the runtime (`Object.keys`) does. +type ElementName = Camelize<`${keyof T & (string | number)}`> + +type GetterNames = `${ElementName}Element` +type PluralNames = `${ElementName}Elements` +type PredicateNames = `has${Capitalize>}Element` + +// Declaration-merging helper: describe the accessors a `static elements` +// definition generates, so controllers get typed `this.xElement` access. +// +// interface MyController extends WithElements<{ backdrop: string }> {} +// class MyController extends Controller { +// static elements = { backdrop: "#backdrop" } +// } +// +// Keys are camelized exactly like the runtime does, so snake_case and +// kebab-case keys yield the same accessor names in both worlds. +// +// Each property is the union of every role that generates its name. For +// non-colliding keys that is a single role and the exact accessor type; +// when keys collide (`foo`/`hasFoo` both produce `hasFooElement`) the +// union forces callers to narrow, since runtime key order decides. +export type WithElements> = { + [P in GetterNames | PluralNames | PredicateNames]: + | (P extends GetterNames ? Element | null : never) + | (P extends PluralNames ? Element[] : never) + | (P extends PredicateNames ? boolean : never) +} diff --git a/src/helpers.ts b/src/helpers.ts index 5c59137..05725f8 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,15 +1,3 @@ -export function camelize(value: string): string { - return value.replace(/[-_]([a-z0-9])/gi, (_match, char: string) => char.toUpperCase()) -} - -export function capitalize(value: string): string { - return value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1) -} - -export function dasherize(value: string): string { - return value.replace(/([A-Z])/g, (_match, char: string) => `-${char.toLowerCase()}`) -} - export function readInheritableStaticObjectPairs( constructor: unknown, propertyName: string, diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 14b9f72..0000000 --- a/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Declaration-merging helper: describe the accessors a `static elements` -// definition generates, so controllers get typed `this.xElement` access. -// -// interface MyController extends WithElements<{ backdrop: string }> {} -// class MyController extends Controller { -// static elements = { backdrop: "#backdrop" } -// } -// -// Keys are assumed already camelCase (matching accessor names). -export type WithElements> = { - [K in keyof T & string as `${K}Element`]: Element | null -} & { - [K in keyof T & string as `${K}Elements`]: Element[] -} & { - [K in keyof T & string as `has${Capitalize}Element`]: boolean -} diff --git a/test/blessing.test.ts b/test/blessing.test.ts index 1c03b65..0fa4d92 100644 --- a/test/blessing.test.ts +++ b/test/blessing.test.ts @@ -142,6 +142,64 @@ test("override applies to plural and has accessors", () => { expect(ctrl.hasBackdropElement).toBe(true) }) +// Characterization: acronym keys keep Stimulus's naive dasherization — +// `htmlURL` maps to the attribute suffix `html-u-r-l`. Locked-in behaviour. +test("acronym key yields naive-dasherized override attribute", () => { + class C { + static elements = { htmlURL: "#backdrop" } + } + const host = fixture() + const ctrl = bless(C, host) + expect(ctrl.htmlURLElement).toBe(host.querySelector("#backdrop")) + expect(ctrl.hasHtmlURLElement).toBe(true) + + host.setAttribute("data-test-html-u-r-l-element", ".item") + expect(ctrl.htmlURLElement).toBe(host.querySelector(".item")) +}) + +test("subclass same-key override does not warn", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + class Base { + static elements = { thing: "#backdrop" } + } + class Child extends Base { + static override elements = { thing: ".item" } + } + bless(Child, fixture()) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() +}) + +test("cross-key predicate collision warns; both getters stay, later predicate wins", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + class C { + // foo and _foo produce distinct getters but the same hasFooElement predicate + static elements = { foo: ".nope", _foo: "#backdrop" } + } + const host = fixture() + const ctrl = bless(C, host) + expect(ctrl.fooElement).toBeNull() + expect(ctrl.FooElement).toBe(host.querySelector("#backdrop")) + // later key (_foo, "#backdrop") wins the shared predicate property + expect(ctrl.hasFooElement).toBe(true) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() +}) + +test("cross-kind collision warns; hasFooElement resolves to later key's getter", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + class C { + // foo's predicate hasFooElement vs hasFoo's singular getter hasFooElement + static elements = { foo: ".item", hasFoo: "#backdrop" } + } + const host = fixture() + const ctrl = bless(C, host) + // later definition's getter shadows the predicate: Element, not boolean + expect(ctrl.hasFooElement).toBe(host.querySelector("#backdrop")) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() +}) + test("invalid override selector warns once and falls back to null / []", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) class C { diff --git a/test/element-definition.test.ts b/test/element-definition.test.ts new file mode 100644 index 0000000..21ec25f --- /dev/null +++ b/test/element-definition.test.ts @@ -0,0 +1,105 @@ +import { test, expect, vi } from "vitest" +import { elementDefinition, mergeElementDefinitions } from "../src/element-definition" + +test("elementDefinition projects the accessor triple and attribute suffix", () => { + expect(elementDefinition("menu_item")).toEqual({ + getterName: "menuItemElement", + pluralName: "menuItemElements", + predicateName: "hasMenuItemElement", + attributeSuffix: "menu-item", + }) +}) + +test("acronym keys keep naive dasherization (locked-in, Stimulus-compatible)", () => { + expect(elementDefinition("htmlURL")).toEqual({ + getterName: "htmlURLElement", + pluralName: "htmlURLElements", + predicateName: "hasHtmlURLElement", + attributeSuffix: "html-u-r-l", + }) +}) + +// Camelization sample table — mirrors the regex /[-_]([a-z0-9])/gi exactly. +// Keep in sync with test/types.test-d.ts. +const camelizeSamples: [key: string, name: string][] = [ + ["menu_item", "menuItem"], + ["menu-item", "menuItem"], + ["backdrop", "backdrop"], + ["alreadyCamel", "alreadyCamel"], + ["foo--bar", "foo-Bar"], // first dash kept + ["foo__bar", "foo_Bar"], // first underscore kept + ["foo-", "foo-"], // trailing separator kept + ["foo_", "foo_"], + ["x--", "x--"], + ["_foo", "Foo"], // leading separator consumed + ["-foo", "Foo"], + ["foo-.bar", "foo-.bar"], // separator before non-alphanumeric kept + ["foo_.bar", "foo_.bar"], + ["foo-Bar", "fooBar"], // i flag: separator consumed, char stays upper + ["foo-1bar", "foo1bar"], // digit consumed unchanged + ["foo-_bar", "foo-Bar"], // dash kept, underscore consumed + ["a-b-c", "aBC"], // scan resumes after each match + ["a-b_c-D", "aBCD"], + ["foo-über", "foo-über"], // regex char class is ASCII-only + ["-1thing", "1thing"], + [ + "a_very_long_element_key_name_that_goes_on_and_on_for_quite_a_while", + "aVeryLongElementKeyNameThatGoesOnAndOnForQuiteAWhile", + ], +] + +test("camelization matches the sample table", () => { + for (const [key, name] of camelizeSamples) { + expect(elementDefinition(key).getterName).toBe(`${name}Element`) + } +}) + +test("predicate for a digit-leading key capitalizes to the same string", () => { + expect(elementDefinition("1thing").predicateName).toBe("has1thingElement") +}) + +test("merge: same raw key later-wins silently (subclass override path)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const merged = mergeElementDefinitions([ + ["thing", "#a"], + ["thing", "#b"], + ]) + expect(merged).toHaveLength(1) + expect(merged[0]!.selector).toBe("#b") + expect(merged[0]!.definition.getterName).toBe("thingElement") + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() +}) + +test("merge: cross-key predicate collision warns and keeps both definitions", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + // foo → fooElement/fooElements/hasFooElement; _foo → FooElement/FooElements/hasFooElement + const merged = mergeElementDefinitions([ + ["foo", ".a"], + ["_foo", ".b"], + ]) + expect(merged).toHaveLength(2) + expect(warn).toHaveBeenCalledTimes(1) + const message = String(warn.mock.calls[0]![0]) + expect(message).toContain('"foo"') + expect(message).toContain('"_foo"') + expect(message).toContain('"hasFooElement"') + expect(message).toContain('".b"') + warn.mockRestore() +}) + +test("merge: cross-kind collision (predicate vs getter) warns once", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + // foo's predicate hasFooElement vs hasFoo's getter hasFooElement + const merged = mergeElementDefinitions([ + ["foo", ".a"], + ["hasFoo", ".b"], + ]) + expect(merged).toHaveLength(2) + expect(warn).toHaveBeenCalledTimes(1) + const message = String(warn.mock.calls[0]![0]) + expect(message).toContain('"foo"') + expect(message).toContain('"hasFoo"') + expect(message).toContain('"hasFooElement"') + warn.mockRestore() +}) diff --git a/test/helpers.test.ts b/test/helpers.test.ts index d7fa1c7..fe1c255 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -1,24 +1,5 @@ import { test, expect } from "vitest" -import { camelize, capitalize, dasherize, readInheritableStaticObjectPairs } from "../src/helpers" - -test("camelize handles snake_case, kebab-case, and passthrough", () => { - expect(camelize("menu_item")).toBe("menuItem") - expect(camelize("menu-item")).toBe("menuItem") - expect(camelize("backdrop")).toBe("backdrop") - expect(camelize("alreadyCamel")).toBe("alreadyCamel") -}) - -test("capitalize uppercases the first character", () => { - expect(capitalize("backdrop")).toBe("Backdrop") - expect(capitalize("")).toBe("") -}) - -test("dasherize splits camelCase into kebab-case", () => { - expect(dasherize("menuItem")).toBe("menu-item") - expect(dasherize("backdrop")).toBe("backdrop") - expect(dasherize("a")).toBe("a") - expect(dasherize("ariaLabelledBy")).toBe("aria-labelled-by") -}) +import { readInheritableStaticObjectPairs } from "../src/helpers" test("readInheritableStaticObjectPairs merges own static props base-first", () => { class Base { diff --git a/test/types.test-d.ts b/test/types.test-d.ts new file mode 100644 index 0000000..004a98d --- /dev/null +++ b/test/types.test-d.ts @@ -0,0 +1,69 @@ +// Type-level assertions, checked by `bun run typecheck` (tsc --noEmit). +// Not a bun test file — the ".test-d." name is deliberately skipped by bun test. +// Camelization sample table mirrors the runtime regex /[-_]([a-z0-9])/gi. +// Keep in sync with test/element-definition.test.ts. +import type { Camelize, WithElements } from "../src/element-definition" + +type Expect = T +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false + +type _Cases = [ + Expect, "menuItem">>, + Expect, "menuItem">>, + Expect, "backdrop">>, + Expect, "alreadyCamel">>, + Expect, "foo-Bar">>, + Expect, "foo_Bar">>, + Expect, "foo-">>, + Expect, "foo_">>, + Expect, "x--">>, + Expect, "Foo">>, + Expect, "Foo">>, + Expect, "foo-.bar">>, + Expect, "foo_.bar">>, + Expect, "fooBar">>, + Expect, "foo1bar">>, + Expect, "foo-Bar">>, + Expect, "aBC">>, + Expect, "aBCD">>, + Expect, "htmlURL">>, + Expect, "foo-über">>, + Expect, "1thing">>, + Expect< + Equal< + Camelize<"a_very_long_element_key_name_that_goes_on_and_on_for_quite_a_while">, + "aVeryLongElementKeyNameThatGoesOnAndOnForQuiteAWhile" + > + >, + // Degenerate and non-literal inputs terminate; union keys distribute. + Expect, "">>, + Expect, string>>, + Expect, "menuItem" | "backdrop">>, +] + +// WithElements: one declared key produces exactly the camelized accessor triple. +type W = WithElements<{ menu_item: string; backdrop: string }> +type _WithElementsCases = [ + Expect< + Equal< + keyof W, + | "menuItemElement" + | "menuItemElements" + | "hasMenuItemElement" + | "backdropElement" + | "backdropElements" + | "hasBackdropElement" + > + >, + Expect>, + Expect>, + Expect>, + Expect, "menuItemElement" | "menuItemElements" | "hasMenuItemElement">>, + Expect, "1thingElement" | "1thingElements" | "has1thingElement">>, + // Numeric keys are stringified like Object.keys does at runtime. + Expect, "1Element" | "1Elements" | "has1Element">>, + // Colliding names get the union of their roles — callers must narrow. + Expect["hasFooElement"], Element | null | boolean>>, +]