-
-
Notifications
You must be signed in to change notification settings - Fork 377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: Behavior #64
Closed
RFC: Behavior #64
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
cc49e23
Add behavior.ts, experimental replacement for modules
marijnh 718883d
Port state module to use behaviors
marijnh 7293d98
Revise the way behavior priorities are handled
marijnh 7315add
Port plugins to use behaviors
marijnh 0caf231
Wire up basic indentation commands using Behavior.indentation
marijnh 43e4f96
Remove .some accessor from SetBehavior
marijnh 7ce7d40
Stricter typing for BehaviorSpec
adrianheine cff18c2
Rename BehaviorSpec to BehaviorUse
adrianheine 817468c
Make Behavior.get a partial function, drop BehaviorSet class
marijnh cba9d18
Make combineConfig a top-level function
marijnh b775ecd
Move viewPlugin to the view module
marijnh 563aedd
Implement separation between extensions and behaviors
marijnh 9195df6
Port the rest of state/ to adjust extension interface
marijnh 4ca5f28
Make all the code use Extension
marijnh 943201b
Comment extension.ts a bit better
marijnh 737e4ee
Make behaviors and extensions functions
marijnh c901cf6
Move behavior subsystem out of state/, so that view/ can also use it
marijnh 5594398
Port extensions and behaviors to yet another approach
marijnh 52dd467
Move behavior tests to the extension package
marijnh 37786d6
Use behavior-style extensions for the view
marijnh 6fb612c
Clean up use of local behavior in history
marijnh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
const none = [] as any | ||
|
||
const enum Kind { BEHAVIOR, MULTI, UNIQUE } | ||
|
||
export class Extension { | ||
// @internal | ||
constructor(/* @internal */ public kind: Kind, | ||
/* @internal */ public id: any, | ||
/* @internal */ public value: any, | ||
/* @internal */ public priority: number = -2) {} | ||
|
||
private setPrio(priority: number): this { | ||
// Crude casting because TypeScript doesn't understand new this.constructor | ||
return new (this.constructor as any)(this.kind, this.id, this.value, priority) as this | ||
} | ||
fallback() { return this.setPrio(-1) } | ||
extend() { return this.setPrio(1) } | ||
override() { return this.setPrio(2) } | ||
|
||
// @internal | ||
flatten(priority: number, target: Extension[] = []) { | ||
if (this.kind == Kind.MULTI) for (let ext of this.value as Extension[]) ext.flatten(this.priority > -2 ? this.priority : priority, target) | ||
else target.push(this.priority > -2 ? this : this.setPrio(priority)) | ||
return target | ||
} | ||
|
||
// Insert this extension in an array of extensions so that it | ||
// appears after any already-present extensions with the same or | ||
// lower priority, but before any extensions with higher priority. | ||
// @internal | ||
collect(array: Extension[]) { | ||
let i = 0 | ||
while (i < array.length && array[i].priority >= this.priority) i++ | ||
array.splice(i, 0, this) | ||
} | ||
|
||
// Define a type of behavior, which is the thing that extensions | ||
// eventually resolve to. Each behavior can have an ordered sequence | ||
// of values associated with it. An `Extension` can be seen as a | ||
// tree of sub-extensions with behaviors as leaves. | ||
static defineBehavior<Value>() { | ||
let behavior = (value: Value) => new this(Kind.BEHAVIOR, behavior, value) | ||
return behavior | ||
} | ||
|
||
static unique<Spec>(instantiate: (specs: Spec[]) => Extension, defaultSpec?: Spec): (spec?: Spec) => Extension { | ||
const type = new UniqueExtensionType(instantiate) | ||
return (spec: Spec | undefined = defaultSpec) => { | ||
if (spec === undefined) throw new RangeError("This extension has no default spec") | ||
return new this(Kind.UNIQUE, type, spec) | ||
} | ||
} | ||
|
||
static all(...extensions: Extension[]) { | ||
return new this(Kind.MULTI, null, extensions) | ||
} | ||
|
||
// Resolve an array of extenders by expanding all extensions until | ||
// only behaviors are left, and then collecting the behaviors into | ||
// arrays of values, preserving priority ordering throughout. | ||
static resolve(extensions: ReadonlyArray<Extension>): BehaviorStore { | ||
let pending: Extension[] = new this(Kind.MULTI, null, extensions).flatten(0) | ||
// This does a crude topological ordering to resolve behaviors | ||
// top-to-bottom in the dependency ordering. If there are no | ||
// cyclic dependencies, we can always find a behavior in the top | ||
// `pending` array that isn't a dependency of any unresolved | ||
// behavior, and thus find and order all its specs in order to | ||
// resolve them. | ||
for (let resolved: UniqueExtensionType[] = [];;) { | ||
let top = findTopUnique(pending, this) | ||
if (!top) break // Only behaviors left | ||
// Prematurely evaluated a behavior type because of missing | ||
// sub-behavior information -- start over, in the assumption | ||
// that newly gathered information will make the next attempt | ||
// more successful. | ||
if (resolved.indexOf(top) > -1) return this.resolve(extensions) | ||
top.resolve(pending) | ||
resolved.push(top) | ||
} | ||
// Collect the behavior values. | ||
let store = new BehaviorStore | ||
for (let ext of pending) { | ||
if (!(ext instanceof this)) { | ||
// Collect extensions of the wrong type into store.foreign | ||
store.foreign.push(ext) | ||
continue | ||
} | ||
if (store.behaviors.indexOf(ext.id) > -1) continue // Already collected | ||
let values: Extension[] = [] | ||
for (let e of pending) if (e.id == ext.id) e.collect(values) | ||
store.behaviors.push(ext.id) | ||
store.values.push(values.map(v => v.value)) | ||
} | ||
return store | ||
} | ||
} | ||
|
||
class UniqueExtensionType { | ||
knownSubs: UniqueExtensionType[] = [] | ||
|
||
constructor(public instantiate: (...specs: any[]) => Extension) {} | ||
|
||
hasSub(type: UniqueExtensionType): boolean { | ||
for (let known of this.knownSubs) | ||
if (known == type || known.hasSub(type)) return true | ||
return false | ||
} | ||
|
||
resolve(extensions: Extension[]) { | ||
// Replace all instances of this type in extneions with the | ||
// sub-extensions that instantiating produces. | ||
let ours: Extension[] = [] | ||
for (let ext of extensions) if (ext.id == this) ext.collect(ours) | ||
let first = true | ||
for (let i = 0; i < extensions.length; i++) { | ||
let ext = extensions[i] | ||
if (ext.id != this) continue | ||
let sub = first ? this.subs(ours.map(s => s.value), ext.priority) : none | ||
extensions.splice(i, 1, ...sub) | ||
first = false | ||
i += sub.length - 1 | ||
} | ||
} | ||
|
||
subs(specs: any[], priority: number) { | ||
let subs = this.instantiate(specs).flatten(priority) | ||
for (let sub of subs) | ||
if (sub.kind == Kind.UNIQUE && this.knownSubs.indexOf(sub.id) == -1) this.knownSubs.push(sub.id) | ||
return subs | ||
} | ||
} | ||
|
||
// An instance of this is part of EditorState and stores the behaviors | ||
// provided for the state. | ||
export class BehaviorStore { | ||
// @internal | ||
behaviors: any[] = [] | ||
// @internal | ||
values: any[][] = [] | ||
// Any extensions that weren't an instance of the given type when | ||
// resolving. | ||
foreign: Extension[] = [] | ||
|
||
get<Value>(behavior: (v: Value) => Extension): Value[] { | ||
let found = this.behaviors.indexOf(behavior) | ||
return found < 0 ? none : this.values[found] | ||
} | ||
} | ||
|
||
// Find the extension type that must be resolved next, meaning it is | ||
// not a (transitive) sub-extension of any other extensions that are | ||
// still in extenders. | ||
function findTopUnique(extensions: Extension[], type: typeof Extension): UniqueExtensionType | null { | ||
let foundUnique = false | ||
for (let ext of extensions) if (ext.kind == Kind.UNIQUE && ext instanceof type) { | ||
foundUnique = true | ||
if (!extensions.some(e => e.kind == Kind.UNIQUE && (e.id as UniqueExtensionType).hasSub(ext.id))) | ||
return ext.id | ||
} | ||
if (foundUnique) throw new RangeError("Sub-extension cycle in unique extensions") | ||
return null | ||
} | ||
|
||
// Utility function for combining behaviors to fill in a config | ||
// object from an array of provided configs. Will, by default, error | ||
// when a field gets two values that aren't ===-equal, but you can | ||
// provide combine functions per field to do something else. | ||
export function combineConfig<Config>(configs: ReadonlyArray<Config>, | ||
combine: {[key: string]: (first: any, second: any) => any} = {}, | ||
defaults?: Config): Config { | ||
let result: any = {} | ||
for (let config of configs) for (let key of Object.keys(config)) { | ||
let value = (config as any)[key], current = result[key] | ||
if (current === undefined) result[key] = value | ||
else if (current === value || value === undefined) {} // No conflict | ||
else if (Object.hasOwnProperty.call(combine, key)) result[key] = combine[key](current, value) | ||
else throw new Error("Config merge conflict for field " + key) | ||
} | ||
if (defaults) for (let key in defaults) | ||
if (result[key] === undefined) result[key] = (defaults as any)[key] | ||
return result | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
const ist = require("ist") | ||
import {Extension} from "../src/extension" | ||
|
||
function mk(...extensions: Extension[]) { return Extension.resolve(extensions) } | ||
|
||
let num = Extension.defineBehavior<number>() | ||
|
||
describe("EditorState behavior", () => { | ||
it("allows querying of behaviors", () => { | ||
let str = Extension.defineBehavior<string>() | ||
let store = mk(num(10), num(20), str("x"), str("y")) | ||
ist(store.get(num).join(), "10,20") | ||
ist(store.get(str).join(), "x,y") | ||
}) | ||
|
||
it("includes sub-extenders", () => { | ||
let e = (s: string) => Extension.all(num(s.length), num(+s)) | ||
let store = mk(num(5), e("20"), num(40), e("100")) | ||
ist(store.get(num).join(), "5,2,20,40,3,100") | ||
}) | ||
|
||
it("only includes sub-behaviors of unique extensions once", () => { | ||
let e = Extension.unique<number>(ns => num(ns.reduce((a, b) => a + b, 0))) | ||
let store = mk(num(1), e(2), num(4), e(8)) | ||
ist(store.get(num).join(), "1,10,4") | ||
}) | ||
|
||
it("returns an empty array for absent behavior", () => { | ||
ist(JSON.stringify(mk().get(num)), "[]") | ||
}) | ||
|
||
it("sorts extensions by priority", () => { | ||
let str = Extension.defineBehavior<string>() | ||
let store = mk(str("a"), str("b"), str("c").extend(), | ||
str("d").override(), str("e").fallback(), | ||
str("f").extend(), str("g")) | ||
ist(store.get(str).join(), "d,c,f,a,b,g,e") | ||
}) | ||
|
||
it("lets sub-extensions inherit their parent's priority", () => { | ||
let e = (n: number) => num(n) | ||
let store = mk(num(1), e(2).override(), e(4)) | ||
ist(store.get(num).join(), "2,1,4") | ||
}) | ||
|
||
it("uses default specs", () => { | ||
let e = Extension.unique((specs: number[]) => num(specs.reduce((a, b) => a + b)), 10) | ||
let store = mk(e(), e(5)) | ||
ist(store.get(num).join(), "15") | ||
}) | ||
|
||
it("only allows omitting use argument when there's a default", () => { | ||
let e = Extension.unique((specs: number[]) => num(0)) | ||
ist.throws(() => e()) | ||
}) | ||
}) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
indentation
would be nicer as a standalone import.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why? Does this go for all of the static values on
StateExtension
? (Would it make sense to continue this discussion in #67 ?)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it goes for all of them. Maybe because they are not extensions, but behaviors? I think
indentation
should beindentationGetter
orgetIndentation
orindentationAt
and they could all have aBehavior
suffix for clarity.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At this point, behaviors can be considered to be a type of extension.
These are not getters—if you call them, you create an extension that holds the value you pass.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, they are not getters, but the behavior provided by
indentation
is »getting the indentation at a specific position«.state.behavior.get(StateExtension.indentation)
returns an array of functions that return the indentation at a specific position.