///|
/// A register name, such as `decision`. It is not blank.
struct RegisterName {
text : String
} derive(Eq, Hash, Debug)
///|
pub fn RegisterName::RegisterName(
text : String,
) -> RegisterName raise ValueError {
if text.iter().all(is_space_char) {
raise BlankName(text)
}
{ text, }
}
///|
pub fn RegisterName::to_string(self : RegisterName) -> String {
self.text
}
///|
pub impl Show for RegisterName with fn output(self, logger) {
logger.write_string(self.text)
}
///|
/// A state of a register, such as `Accepted`. It is not blank, and it is
/// compared exactly as written.
struct StateName {
text : String
} derive(Eq, Hash, Debug)
///|
pub fn StateName::StateName(text : String) -> StateName raise ValueError {
if text.iter().all(is_space_char) {
raise BlankName(text)
}
{ text, }
}
///|
pub fn StateName::to_string(self : StateName) -> String {
self.text
}
///|
pub impl Show for StateName with fn output(self, logger) {
logger.write_string(self.text)
}
///|
/// A record ID, such as `0042`. It is not blank.
struct RecordId {
text : String
} derive(Eq, Hash, Debug)
///|
pub fn RecordId::RecordId(text : String) -> RecordId raise ValueError {
if text.iter().all(is_space_char) {
raise BlankName(text)
}
{ text, }
}
///|
pub fn RecordId::to_string(self : RecordId) -> String {
self.text
}
///|
pub impl Show for RecordId with fn output(self, logger) {
logger.write_string(self.text)
}
///|
pub impl Compare for RecordId with fn compare(self, other) {
lexical_compare(self.text, other.text)
}
///|
/// The width of a padded record ID: 1 to 9 digits.
struct IdWidth {
value : Int32
} derive(Eq, Hash, Debug)
///|
pub fn IdWidth::IdWidth(value : Int32) -> IdWidth raise ValueError {
if value < 1 || value > 9 {
raise InvalidIdWidth(value)
}
{ value, }
}
///|
/// The width 4, as in `0042`.
pub fn IdWidth::default() -> IdWidth {
{ value: 4, }
}
///|
pub fn IdWidth::to_int(self : IdWidth) -> Int32 {
self.value
}
///|
/// How a register finds the ID of a record.
pub(all) enum IdRule {
/// The leading digits of the file name, padded with zeros to the width.
LeadingDigits(IdWidth)
/// The value of a frontmatter key.
FieldValue(FieldKey)
} derive(Eq, Debug)
///|
/// One record of a register: a typed view of one concept.
pub struct Record {
register : RegisterName
bundle : BundleLabel
id : RecordId?
state : StateName?
document : Document
}
///|
pub fn Record::key(self : Record) -> DocKey {
{ bundle: self.bundle, path: self.document.path(), }
}
///|
/// The frontmatter of the record's concept.
/// The same record with another text of its document, such as the preview of
/// an edit. The ID and the state stay as they were, so a transition check on
/// the preview starts from the current state.
pub fn Record::with_document(self : Record, document : Document) -> Record {
{ ..self, document, }
}
///|
pub fn Record::metadata(self : Record) -> Metadata {
self.document.metadata()
}
///|
/// A condition that a transition requires, such as a `reason` value. The name
/// is a label for reports.
pub(all) struct Obligation {
name : String
met : (Record) -> Bool
}
///|
/// An obligation that holds when the record has a non-empty value for `key`.
pub fn Obligation::requires_key(key : FieldKey) -> Obligation {
{
name: key.to_string(),
met: r => r.metadata().text(key.to_string()) is Some(_),
}
}
///|
/// An allowed change of state. An empty `from` allows any current state.
struct Transition {
from : ReadOnlyArray[StateName]
to : StateName
obligations : ReadOnlyArray[Obligation]
}
///|
pub fn Transition::Transition(
to : StateName,
from~ : ArrayView[StateName] = [],
obligations~ : ArrayView[Obligation] = [],
) -> Transition {
{
from: ReadOnlyArray::from_array(from.to_owned()),
to,
obligations: ReadOnlyArray::from_array(obligations.to_owned()),
}
}
///|
pub fn Transition::from(self : Transition) -> ReadOnlyArray[StateName] {
self.from
}
///|
pub fn Transition::to(self : Transition) -> StateName {
self.to
}
///|
pub fn Transition::obligations(self : Transition) -> ReadOnlyArray[Obligation] {
self.obligations
}
///|
/// A typed view of the concepts of one type, defined by a profile. Build it
/// with `Register::Register`, which checks its parts.
struct Register {
name : RegisterName
concept_type : ConceptType
id_rule : IdRule
state_key : FieldKey?
states : ReadOnlyArray[StateName]
transitions : ReadOnlyArray[Transition]
supersedes_key : FieldKey?
superseded_by_key : FieldKey?
superseded_states : ReadOnlyArray[StateName]
bundle_flag : FieldKey?
fields : ReadOnlyArray[KeyDefinition]
}
///|
/// Builds a register. When `states` is not empty, every transition state and
/// every superseded state must be one of them; otherwise it raises
/// `UnknownState`.
pub fn Register::Register(
name : RegisterName,
concept_type : ConceptType,
id_rule~ : IdRule = LeadingDigits(IdWidth::default()),
state_key? : FieldKey,
states~ : ArrayView[StateName] = [],
transitions~ : ArrayView[Transition] = [],
supersedes_key? : FieldKey,
superseded_by_key? : FieldKey,
superseded_states~ : ArrayView[StateName] = [],
bundle_flag? : FieldKey,
fields~ : ArrayView[KeyDefinition] = [],
) -> Register raise ValueError {
let known = states.to_owned()
if !known.is_empty() {
let used : Array[StateName] = []
for t in transitions {
used.push(t.to)
for f in t.from {
used.push(f)
}
}
for s in superseded_states {
used.push(s)
}
for s in used {
if !known.contains(s) {
raise ValueError::UnknownState(
register=name.to_string(),
state=s.to_string(),
)
}
}
}
{
name,
concept_type,
id_rule,
state_key,
states: ReadOnlyArray::from_array(known),
transitions: ReadOnlyArray::from_array(transitions.to_owned()),
supersedes_key,
superseded_by_key,
superseded_states: ReadOnlyArray::from_array(superseded_states.to_owned()),
bundle_flag,
fields: ReadOnlyArray::from_array(fields.to_owned()),
}
}
///|
pub fn Register::name(self : Register) -> RegisterName {
self.name
}
///|
pub fn Register::concept_type(self : Register) -> ConceptType {
self.concept_type
}
///|
pub fn Register::id_rule(self : Register) -> IdRule {
self.id_rule
}
///|
pub fn Register::state_key(self : Register) -> FieldKey? {
self.state_key
}
///|
pub fn Register::states(self : Register) -> ReadOnlyArray[StateName] {
self.states
}
///|
pub fn Register::transitions(self : Register) -> ReadOnlyArray[Transition] {
self.transitions
}
///|
pub fn Register::supersedes_key(self : Register) -> FieldKey? {
self.supersedes_key
}
///|
pub fn Register::superseded_by_key(self : Register) -> FieldKey? {
self.superseded_by_key
}
///|
pub fn Register::superseded_states(self : Register) -> ReadOnlyArray[StateName] {
self.superseded_states
}
///|
pub fn Register::bundle_flag(self : Register) -> FieldKey? {
self.bundle_flag
}
///|
pub fn Register::fields(self : Register) -> ReadOnlyArray[KeyDefinition] {
self.fields
}
///|
/// Normalizes an ID: digits only, padded with zeros to `width`. Text that is
/// not all digits gives `None`.
pub fn normalize_id(text : String, width : IdWidth) -> RecordId? {
let text = text.trim(chars=" \t").to_owned()
if text.length() == 0 {
return None
}
for c in text {
if c < '0' || c > '9' {
return None
}
}
let mut i = 0
while i < text.length() - 1 && text[i] == '0' {
i += 1
}
let trimmed = text[i:].to_owned()
let w = width.to_int()
let padded = if trimmed.length() < w {
"0".repeat(w - trimmed.length()) + trimmed
} else {
trimmed
}
Some({ text: padded, })
}
///|
fn leading_digits(name : String) -> String? {
let mut i = 0
while i < name.length() && name[i] >= '0' && name[i] <= '9' {
i += 1
}
if i == 0 {
None
} else {
Some(name[:i].to_owned())
}
}
///|
/// True when the register applies to `bundle`: no bundle flag, or the root
/// index sets the flag to true.
pub fn Register::applies_to(self : Register, bundle : Bundle) -> Bool {
match self.bundle_flag {
None => true
Some(flag) => bundle.config().bool(flag.to_string()) == Some(true)
}
}
///|
/// The ID of a document under this register.
pub fn Register::record_id(self : Register, doc : Document) -> RecordId? {
match self.id_rule {
LeadingDigits(width) =>
match leading_digits(doc.path().name()) {
Some(digits) => normalize_id(digits, width)
None => None
}
FieldValue(key) =>
match doc.metadata().text(key.to_string()) {
// A blank field gives no ID.
Some(text) =>
try RecordId(text) catch {
_ => None
} noraise {
id => Some(id)
}
None => None
}
}
}
///|
/// The record ID for an ID that a user or a field gives, such as `42`.
pub fn Register::parse_id(self : Register, text : String) -> RecordId? {
match self.id_rule {
LeadingDigits(width) => normalize_id(text, width)
FieldValue(_) =>
if text.iter().all(is_space_char) {
None
} else {
Some({ text, })
}
}
}
///|
fn Register::state_of(self : Register, doc : Document) -> StateName? {
match self.state_key {
Some(key) =>
match doc.metadata().text(key.to_string()) {
Some(text) if !text.iter().all(is_space_char) => Some({ text, })
_ => None
}
None => None
}
}
///|
/// The records of `bundle`: its concepts whose `type` equals the register
/// type, ignoring letter case.
pub fn Register::records(self : Register, bundle : Bundle) -> Array[Record] {
guard self.applies_to(bundle) else { return [] }
bundle
.concepts()
.iter()
.filter(doc => doc.concept_type() == Some(self.concept_type))
.map(doc => {
register: self.name,
bundle: bundle.label(),
id: self.record_id(doc),
state: self.state_of(doc),
document: doc,
})
.collect()
}
///|
/// The records of every bundle where the register applies.
pub fn Register::all_records(
self : Register,
knowledge : Knowledge,
) -> Array[Record] {
let out = []
for bundle in knowledge.bundles() {
for record in self.records(bundle) {
out.push(record)
}
}
out
}
///|
/// Finds records by ID (`42` or `0042`) or by a part of the file name.
pub fn Register::find(
self : Register,
records : ArrayView[Record],
query : String,
) -> Lookup[Record] {
match self.parse_id(query) {
Some(id) => {
let by_id = records.iter().filter(r => r.id == Some(id)).collect()
if !by_id.is_empty() {
return lookup_of(by_id)
}
}
None => ()
}
let q = query.to_lower()
lookup_of(
records
.iter()
.filter(r => r.document.path().name().to_lower().contains(q))
.collect(),
)
}
///|
/// The IDs that more than one record has, with those records, sorted by ID.
pub fn Register::duplicates(
_self : Register,
records : ArrayView[Record],
) -> Array[(RecordId, ReadOnlyArray[Record])] {
let groups : Map[RecordId, Array[Record]] = Map([])
for record in records {
match record.id {
Some(id) =>
match groups.get(id) {
Some(list) => list.push(record)
None => groups[id] = [record]
}
None => ()
}
}
let out = []
for id, list in groups {
if list.length() > 1 {
out.push((id, ReadOnlyArray::from_array(list)))
}
}
out.sort_by((a, b) => a.0.compare(b.0))
out
}
///|
/// Why a register refuses a transition.
pub(all) enum TransitionDenial {
/// The target state is not a state of the register.
UnknownState(StateName)
/// No transition leads from the current state to the target state.
NotAllowed(from~ : StateName?, to~ : StateName)
/// The transition exists, but the obligations with these names are not met.
Refused(ReadOnlyArray[String])
} derive(Eq, Debug)
///|
/// Checks whether `record` can move to state `to`: `Ok` when it can, else the
/// reason why not.
pub fn Register::check_transition(
self : Register,
record : Record,
to : StateName,
) -> Result[Unit, TransitionDenial] {
if !self.states.is_empty() && !self.states.contains(to) {
return Err(UnknownState(to))
}
let candidates = self.transitions
.iter()
.filter(t => {
t.to == to &&
(
t.from.is_empty() ||
(match record.state {
Some(s) => t.from.contains(s)
None => false
})
)
})
.collect()
match candidates {
[] => Err(NotAllowed(from=record.state, to~))
_ => {
let mut best : Array[String]? = None
for t in candidates {
let unmet = t.obligations
.iter()
.filter(o => !(o.met)(record))
.map(o => o.name)
.collect()
if unmet.is_empty() {
return Ok(())
}
match best {
Some(b) if b.length() <= unmet.length() => ()
_ => best = Some(unmet)
}
}
Err(Refused(ReadOnlyArray::from_array(best.unwrap_or([]))))
}
}
}
///|
/// A supersession problem.
pub(all) enum SupersessionIssue {
/// A record in a superseded state names no successor.
MissingSuccessor(Record)
/// `superseded_by` names an ID that no record has.
UnknownSuccessor(Record, RecordId)
/// `supersedes` names an ID that no record has.
UnknownPredecessor(Record, RecordId)
/// Only one of the two records names the other.
NotMutual(Record, Record)
}
///|
fn Register::ids_at(
self : Register,
record : Record,
key : FieldKey?,
) -> Array[RecordId] {
// Text that is not an ID of this register stays as written, so that the
// checks report it as unknown. Blank text names no record.
let id_of = (text : String) => {
match self.parse_id(text) {
Some(id) => Some(id)
None =>
try RecordId(text.trim(chars=" \t").to_owned()) catch {
_ => None
} noraise {
id => Some(id)
}
}
}
match key {
Some(k) => record.metadata().texts(k.to_string()).filter_map(id_of)
None => []
}
}
///|
/// Checks the supersession fields of `records`, which come from one bundle.
pub fn Register::supersession_issues(
self : Register,
records : ArrayView[Record],
) -> Array[SupersessionIssue] {
let by_id : Map[RecordId, Record] = Map([])
for r in records {
match r.id {
Some(id) => if !by_id.contains(id) { by_id[id] = r }
None => ()
}
}
let names = (r : Record, ids : Array[RecordId]) => {
match r.id {
Some(id) => ids.contains(id)
None => false
}
}
let out = []
for r in records {
let successors = self.ids_at(r, self.superseded_by_key)
match r.state {
Some(state) if self.superseded_states.contains(state) &&
successors.is_empty() => out.push(MissingSuccessor(r))
_ => ()
}
for s in successors {
match by_id.get(s) {
None => out.push(UnknownSuccessor(r, s))
Some(next) =>
if self.supersedes_key is Some(_) &&
!names(r, self.ids_at(next, self.supersedes_key)) {
out.push(NotMutual(r, next))
}
}
}
for p in self.ids_at(r, self.supersedes_key) {
match by_id.get(p) {
None => out.push(UnknownPredecessor(r, p))
Some(prev) =>
if self.superseded_by_key is Some(_) &&
!names(r, self.ids_at(prev, self.superseded_by_key)) {
out.push(NotMutual(prev, r))
}
}
}
}
out
}
///|
/// The chain of successors of `record`, following `superseded_by`. A cycle ends the chain.
pub fn Register::successors(
self : Register,
records : ArrayView[Record],
record : Record,
) -> Array[Record] {
let out = []
let seen : Array[RecordId] = match record.id {
Some(id) => [id]
None => []
}
let mut current = record
while true {
match self.ids_at(current, self.superseded_by_key) {
[next_id, ..] => {
if seen.contains(next_id) {
break
}
match records.iter().find_first(r => r.id == Some(next_id)) {
Some(next) => {
out.push(next)
seen.push(next_id)
current = next
}
None => break
}
}
[] => break
}
}
out
}
///|
/// What a reference field names.
pub(all) enum ReferenceKind {
/// A document: `bundle-label:/path.md`, or `/path.md` in the same bundle.
DocumentRef
/// Records of a register in the same bundle, by ID.
RecordRef(register~ : RegisterName)
/// A path or URL, resolved like a link.
PathRef
} derive(Eq, Debug)
///|
/// A frontmatter field whose values name documents or records.
pub(all) struct ReferenceField {
key : FieldKey
kind : ReferenceKind
} derive(Eq, Debug)
///|
/// A template for new concepts of one type. The body can use `{{title}}`,
/// `{{description}}` and `{{type}}`.
pub(all) struct Template {
concept_type : ConceptType
body : String
} derive(Eq, Debug)
///|
pub extend RegisterName with Eq::{not_equal, equal}
///|
pub extend RegisterName with Hash::{hash, hash_combine}
///|
pub extend RegisterName with @debug.Debug::{to_repr}
///|
pub extend RegisterName with Show::{output}
///|
pub extend StateName with Eq::{not_equal, equal}
///|
pub extend StateName with Hash::{hash, hash_combine}
///|
pub extend StateName with @debug.Debug::{to_repr}
///|
pub extend StateName with Show::{output}
///|
pub extend RecordId with Eq::{not_equal, equal}
///|
pub extend RecordId with Hash::{hash, hash_combine}
///|
pub extend RecordId with @debug.Debug::{to_repr}
///|
pub extend RecordId with Show::{output}
///|
pub extend RecordId with Compare::{op_lt, op_le, op_ge, compare, op_gt}
///|
pub extend IdWidth with Eq::{not_equal, equal}
///|
pub extend IdWidth with Hash::{hash, hash_combine}
///|
pub extend IdWidth with @debug.Debug::{to_repr}
///|
pub extend IdRule with Eq::{not_equal, equal}
///|
pub extend IdRule with @debug.Debug::{to_repr}
///|
pub extend TransitionDenial with Eq::{not_equal, equal}
///|
pub extend TransitionDenial with @debug.Debug::{to_repr}
///|
pub extend ReferenceKind with Eq::{not_equal, equal}
///|
pub extend ReferenceKind with @debug.Debug::{to_repr}
///|
pub extend ReferenceField with Eq::{not_equal, equal}
///|
pub extend ReferenceField with @debug.Debug::{to_repr}
///|
pub extend Template with Eq::{not_equal, equal}
///|
pub extend Template with @debug.Debug::{to_repr}