Index: api/api.go ================================================================== --- api/api.go +++ api/api.go @@ -12,29 +12,11 @@ //----------------------------------------------------------------------------- // Package api contains common definitions used for client and server. package api -// ZettelID contains the identifier of a zettel. It is a string with 14 digits. -type ZettelID string - -// InvalidZID is an invalid zettel identifier -const InvalidZID = "" - -// IsValid returns true, if the idenfifier contains 14 digits. -func (zid ZettelID) IsValid() bool { - if len(zid) != 14 { - return false - } - for i := range 14 { - ch := zid[i] - if ch < '0' || '9' < ch { - return false - } - } - return true -} +import "t73f.de/r/zsc/domain/id" // ZettelMeta is a map containg the normalized metadata of a zettel. type ZettelMeta map[string]string // ZettelRights is an integer that encode access rights for a zettel. @@ -57,11 +39,11 @@ Rights ZettelRights } // ZidMetaRights contains the identifier, the metadata of a zettel, and its rights. type ZidMetaRights struct { - ID ZettelID + ID id.Zid Meta ZettelMeta Rights ZettelRights } // ZettelData contains all data for a zettel. @@ -76,6 +58,6 @@ Encoding string Content string // raw, uninterpreted zettel content } // Aggregate maps metadata keys to list of zettel identifier. -type Aggregate map[string][]ZettelID +type Aggregate map[string][]id.Zid Index: api/const.go ================================================================== --- api/const.go +++ api/const.go @@ -13,183 +13,10 @@ package api import "fmt" -// Predefined zettel identifier. -// -// See [List of predefined zettel]. -// -// [List of predefined zettel]: https://zettelstore.de/manual/h/00001005090000 -const ( - // System zettel - ZidVersion = ZettelID("00000000000001") - ZidHost = ZettelID("00000000000002") - ZidOperatingSystem = ZettelID("00000000000003") - ZidLicense = ZettelID("00000000000004") - ZidAuthors = ZettelID("00000000000005") - ZidDependencies = ZettelID("00000000000006") - ZidLog = ZettelID("00000000000007") - ZidMemory = ZettelID("00000000000008") - ZidSx = ZettelID("00000000000009") - ZidHTTP = ZettelID("00000000000010") - ZidAPI = ZettelID("00000000000011") - ZidWebUI = ZettelID("00000000000012") - ZidConsole = ZettelID("00000000000013") - ZidBoxManager = ZettelID("00000000000020") - ZidZettel = ZettelID("00000000000021") - ZidIndex = ZettelID("00000000000022") - ZidQuery = ZettelID("00000000000023") - ZidMetadataKey = ZettelID("00000000000090") - ZidParser = ZettelID("00000000000092") - ZidStartupConfiguration = ZettelID("00000000000096") - ZidConfiguration = ZettelID("00000000000100") - ZidDirectory = ZettelID("00000000000101") - - // WebUI HTML templates are in the range 10000..19999 - ZidBaseTemplate = ZettelID("00000000010100") - ZidLoginTemplate = ZettelID("00000000010200") - ZidListTemplate = ZettelID("00000000010300") - ZidZettelTemplate = ZettelID("00000000010401") - ZidInfoTemplate = ZettelID("00000000010402") - ZidFormTemplate = ZettelID("00000000010403") - ZidDeleteTemplate = ZettelID("00000000010405") - ZidErrorTemplate = ZettelID("00000000010700") - - // WebUI sxn code zettel are in the range 19000..19999 - ZidSxnStart = ZettelID("00000000019000") - ZidSxnBase = ZettelID("00000000019990") - - // CSS-related zettel are in the range 20000..29999 - ZidBaseCSS = ZettelID("00000000020001") - ZidUserCSS = ZettelID("00000000025001") - - // WebUI JS zettel are in the range 30000..39999 - - // WebUI image zettel are in the range 40000..49999 - ZidEmoji = ZettelID("00000000040001") - - // Other sxn code zettel are in the range 50000..59999 - ZidSxnPrelude = ZettelID("00000000059900") - - // Predefined Zettelmarkup zettel are in the range 60000..69999 - ZidRoleZettelZettel = ZettelID("00000000060010") - ZidRoleConfigurationZettel = ZettelID("00000000060020") - ZidRoleRoleZettel = ZettelID("00000000060030") - ZidRoleTagZettel = ZettelID("00000000060040") - - // Range 90000...99999 is reserved for zettel templates - ZidTOCNewTemplate = ZettelID("00000000090000") - ZidTemplateNewZettel = ZettelID("00000000090001") - ZidTemplateNewRole = ZettelID("00000000090004") - ZidTemplateNewTag = ZettelID("00000000090003") - ZidTemplateNewUser = ZettelID("00000000090002") - - // Range 00000999999900...00000999999999 are predefined zettel to be searched by content. - ZidAppDirectory = ZettelID("00000999999999") - - // Default Home Zettel - ZidDefaultHome = ZettelID("00010000000000") -) - -// LengthZid factors the constant length of a zettel identifier -const LengthZid = len(ZidDefaultHome) - -// Values of the metadata key/value types. -// -// See [Supported Key Types]. -// -// [Supported Key Types]: https://zettelstore.de/manual/h/00001006030000 -const ( - MetaCredential = "Credential" - MetaEmpty = "EString" - MetaID = "Identifier" - MetaIDSet = "IdentifierSet" - MetaNumber = "Number" - MetaString = "String" - MetaTagSet = "TagSet" - MetaTimestamp = "Timestamp" - MetaURL = "URL" - MetaWord = "Word" - MetaZettelmarkup = "Zettelmarkup" -) - -// Predefined / supported metadata keys. -// -// See [Supported Metadata Keys]. -// -// [Supported Metadata Keys]: https://zettelstore.de/manual/h/00001006020000 -const ( - KeyID = "id" - KeyTitle = "title" - KeyRole = "role" - KeyTags = "tags" - KeySyntax = "syntax" - KeyAuthor = "author" - KeyBack = "back" - KeyBackward = "backward" - KeyBoxNumber = "box-number" - KeyCopyright = "copyright" - KeyCreated = "created" - KeyCredential = "credential" - KeyDead = "dead" - KeyExpire = "expire" - KeyFolge = "folge" - KeyFolgeRole = "folge-role" - KeyForward = "forward" - KeyLang = "lang" - KeyLicense = "license" - KeyModified = "modified" - KeyPrecursor = "precursor" - KeyPredecessor = "predecessor" - KeyPrequel = "prequel" - KeyPublished = "published" - KeyQuery = "query" - KeyReadOnly = "read-only" - KeySequel = "sequel" - KeySubordinates = "subordinates" - KeySuccessors = "successors" - KeySummary = "summary" - KeySuperior = "superior" - KeyURL = "url" - KeyUselessFiles = "useless-files" - KeyUserID = "user-id" - KeyUserRole = "user-role" - KeyVisibility = "visibility" -) - -// Predefined metadata values. -const ( - ValueFalse = "false" - ValueTrue = "true" - ValueLangEN = "en" // Default for "lang" - ValueRoleConfiguration = "configuration" // A role for internal zettel - ValueRoleTag = "tag" // A role for tag zettel - ValueRoleRole = "role" // A role for role zettel - ValueRoleZettel = "zettel" // A role for zettel - ValueSyntaxCSS = "css" // Syntax: CSS - ValueSyntaxDraw = "draw" // Syntax: Drawing - ValueSyntaxGif = "gif" // Syntax GIF image - ValueSyntaxHTML = "html" // Syntax: HTML - ValueSyntaxMarkdown = "markdown" // Syntax: Markdown / CommonMark - ValueSyntaxMD = "md" // Syntax: Markdown / CommonMark - ValueSyntaxNone = "none" // Syntax: no syntax / content, just metadata - ValueSyntaxSVG = "svg" // Syntax: SVG - ValueSyntaxSxn = "sxn" // Syntax: S-Expression - ValueSyntaxText = "text" // Syntax: plain text - ValueSyntaxZmk = "zmk" // Syntax: Zettelmarkup - ValueUserRoleCreator = "creator" - ValueUserRoleOwner = "owner" - ValueUserRoleReader = "reader" - ValueUserRoleWriter = "writer" - ValueVisibilityCreator = "creator" - ValueVisibilityExpert = "expert" - ValueVisibilityLogin = "login" - ValueVisibilityOwner = "owner" - ValueVisibilityPublic = "public" -) - // Additional HTTP constants. const ( HeaderAccept = "Accept" HeaderContentType = "Content-Type" HeaderDestination = "Destination" @@ -298,10 +125,11 @@ ForwardDirective = "FORWARD" // Forward-only context FullDirective = "FULL" // Include tags in context IdentDirective = "IDENT" // Use only specified zettel ItemsDirective = "ITEMS" // Select list elements in a zettel MaxDirective = "MAX" // Maximum number of context results + MinDirective = "MIN" // Minimum number of context results LimitDirective = "LIMIT" // Maximum number of zettel OffsetDirective = "OFFSET" // Offset to start returned zettel list OrDirective = "OR" // Combine several search expression with an "or" OrderDirective = "ORDER" // Specify metadata keys for the order of returned list PhraseDirective = "PHRASE" // Only unlinked zettel with given phrase @@ -310,19 +138,16 @@ ReverseDirective = "REVERSE" // Reverse the order of a zettel list UnlinkedDirective = "UNLINKED" // Search for zettel that contain a phase(s) but do not link ActionSeparator = "|" // Separates action list of previous elements of query expression - AtomAction = "ATOM" // Return an Atom web feed KeysAction = "KEYS" // Provide metadata key used MinAction = "MIN" // Return only those values with a minimum amount of zettel MaxAction = "MAX" // Return only those values with a maximum amount of zettel NumberedAction = "NUMBERED" // Return a numbered list RedirectAction = "REDIRECT" // Return the first zettel in list ReIndexAction = "REINDEX" // Ensure that zettel is/are indexed. - RSSAction = "RSS" // Return a RSS web feed - TitleAction = "TITLE" // Set a title for Atom or RSS web feed ExistOperator = "?" // Does zettel have metadata with given key? ExistNotOperator = "!?" // True id zettel does not have metadata with given key. SearchOperatorNot = "!" Index: api/urlbuilder.go ================================================================== --- api/urlbuilder.go +++ api/urlbuilder.go @@ -11,11 +11,14 @@ // SPDX-FileCopyrightText: 2020-present Detlef Stern //----------------------------------------------------------------------------- package api -import "t73f.de/r/webs/urlbuilder" +import ( + "t73f.de/r/webs/urlbuilder" + "t73f.de/r/zsc/domain/id" +) // URLBuilder should be used to create zettelstore URLs. type URLBuilder struct { base urlbuilder.URLBuilder prefix string @@ -40,12 +43,12 @@ cpy.prefix = ub.prefix return cpy } // SetZid sets the zettel identifier. -func (ub *URLBuilder) SetZid(zid ZettelID) *URLBuilder { - ub.base.AddPath(string(zid)) +func (ub *URLBuilder) SetZid(zid id.Zid) *URLBuilder { + ub.base.AddPath(zid.String()) return ub } // AppendPath adds a new path element. func (ub *URLBuilder) AppendPath(p string) *URLBuilder { Index: attrs/attrs.go ================================================================== --- attrs/attrs.go +++ attrs/attrs.go @@ -13,14 +13,13 @@ // Package attrs stores attributes of zettel parts. package attrs import ( + "maps" "slices" "strings" - - "t73f.de/r/zsc/maps" ) // Attributes store additional information about some node types. type Attributes map[string]string @@ -46,11 +45,11 @@ } return a } // Keys returns the sorted list of keys. -func (a Attributes) Keys() []string { return maps.Keys(a) } +func (a Attributes) Keys() []string { return slices.Sorted(maps.Keys(a)) } // Get returns the attribute value of the given key and a succes value. func (a Attributes) Get(key string) (string, bool) { if a != nil { value, ok := a[key] @@ -58,20 +57,11 @@ } return "", false } // Clone returns a duplicate of the attribute. -func (a Attributes) Clone() Attributes { - if a == nil { - return nil - } - attrs := make(map[string]string, len(a)) - for k, v := range a { - attrs[k] = v - } - return attrs -} +func (a Attributes) Clone() Attributes { return maps.Clone(a) } // Set changes the attribute that a given key has now a given value. func (a Attributes) Set(key, value string) Attributes { if a == nil { return map[string]string{key: value} Index: client/client.go ================================================================== --- client/client.go +++ client/client.go @@ -27,10 +27,11 @@ "time" "t73f.de/r/sx" "t73f.de/r/sx/sxreader" "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/id" "t73f.de/r/zsc/sexp" ) // Client contains all data to execute requests. type Client struct { @@ -187,11 +188,11 @@ func (c *Client) executeAuthRequest(req *http.Request) error { resp, err := c.executeRequest(req) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return statusToError(resp) } rd := sxreader.MakeReader(resp.Body) obj, err := rd.Read() @@ -249,115 +250,112 @@ // CreateZettel creates a new zettel and returns its URL. // // data contains the zettel metadata and content, as it is stored in a file in a zettel box, // or as returned by [Client.GetZettel]. // Metadata is separated from zettel content by an empty line. -func (c *Client) CreateZettel(ctx context.Context, data []byte) (api.ZettelID, error) { +func (c *Client) CreateZettel(ctx context.Context, data []byte) (id.Zid, error) { ub := c.NewURLBuilder('z') resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, bytes.NewBuffer(data)) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { - return api.InvalidZID, statusToError(resp) + return id.Invalid, statusToError(resp) } b, err := io.ReadAll(resp.Body) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } - if zid := api.ZettelID(b); zid.IsValid() { - return zid, nil - } - return api.InvalidZID, err + return id.Parse(string(b)) } // CreateZettelData creates a new zettel and returns its URL. // // data contains the zettel date, encoded as explicit struct. -func (c *Client) CreateZettelData(ctx context.Context, data api.ZettelData) (api.ZettelID, error) { +func (c *Client) CreateZettelData(ctx context.Context, data api.ZettelData) (id.Zid, error) { var buf bytes.Buffer if _, err := sx.Print(&buf, sexp.EncodeZettel(data)); err != nil { - return api.InvalidZID, err + return id.Invalid, err } ub := c.NewURLBuilder('z').AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, &buf) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() rdr := sxreader.MakeReader(resp.Body) obj, err := rdr.Read() if resp.StatusCode != http.StatusCreated { - return api.InvalidZID, statusToError(resp) + return id.Invalid, statusToError(resp) } if err != nil { - return api.InvalidZID, err + return id.Invalid, err } return makeZettelID(obj) } -func makeZettelID(obj sx.Object) (api.ZettelID, error) { +func makeZettelID(obj sx.Object) (id.Zid, error) { val, isInt64 := obj.(sx.Int64) if !isInt64 || val <= 0 { - return api.InvalidZID, fmt.Errorf("invalid zettel ID: %v", val) + return id.Invalid, fmt.Errorf("invalid zettel ID: %v", val) } sVal := strconv.FormatInt(int64(val), 10) if len(sVal) < 14 { sVal = "00000000000000"[0:14-len(sVal)] + sVal } - zid := api.ZettelID(sVal) - if !zid.IsValid() { - return api.InvalidZID, fmt.Errorf("invalid zettel ID: %v", val) + zid, err := id.Parse(sVal) + if err != nil { + return id.Invalid, fmt.Errorf("invalid zettel ID %v: %w", val, err) } return zid, nil } // UpdateZettel updates an existing zettel, specified by its zettel identifier. // // data contains the zettel metadata and content, as it is stored in a file in a zettel box, // or as returned by [Client.GetZettel]. // Metadata is separated from zettel content by an empty line. -func (c *Client) UpdateZettel(ctx context.Context, zid api.ZettelID, data []byte) error { +func (c *Client) UpdateZettel(ctx context.Context, zid id.Zid, data []byte) error { ub := c.NewURLBuilder('z').SetZid(zid) resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, bytes.NewBuffer(data)) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } // UpdateZettelData updates an existing zettel, specified by its zettel identifier. -func (c *Client) UpdateZettelData(ctx context.Context, zid api.ZettelID, data api.ZettelData) error { +func (c *Client) UpdateZettelData(ctx context.Context, zid id.Zid, data api.ZettelData) error { var buf bytes.Buffer if _, err := sx.Print(&buf, sexp.EncodeZettel(data)); err != nil { return err } ub := c.NewURLBuilder('z').SetZid(zid).AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, &buf) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } // DeleteZettel deletes a zettel with the given identifier. -func (c *Client) DeleteZettel(ctx context.Context, zid api.ZettelID) error { +func (c *Client) DeleteZettel(ctx context.Context, zid id.Zid) error { ub := c.NewURLBuilder('z').SetZid(zid) resp, err := c.buildAndExecuteRequest(ctx, http.MethodDelete, ub, nil) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } @@ -371,11 +369,11 @@ ub := c.NewURLBuilder('x').AppendKVQuery(api.QueryKeyCommand, string(command)) resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, nil) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } Index: client/client_test.go ================================================================== --- client/client_test.go +++ client/client_test.go @@ -20,10 +20,11 @@ "net/url" "testing" "t73f.de/r/zsc/api" "t73f.de/r/zsc/client" + "t73f.de/r/zsc/domain/id" ) func TestZettelList(t *testing.T) { c := getClient() _, err := c.QueryZettel(context.Background(), "") @@ -33,11 +34,11 @@ } } func TestGetProtectedZettel(t *testing.T) { c := getClient() - _, err := c.GetZettel(context.Background(), api.ZidStartupConfiguration, api.PartZettel) + _, err := c.GetZettel(context.Background(), id.ZidStartupConfiguration, api.PartZettel) if err != nil { if cErr, ok := err.(*client.Error); ok && cErr.StatusCode == http.StatusForbidden { return } else { t.Error(err) @@ -46,11 +47,11 @@ } } func TestGetSzZettel(t *testing.T) { c := getClient() - value, err := c.GetEvaluatedSz(context.Background(), api.ZidDefaultHome, api.PartContent) + value, err := c.GetEvaluatedSz(context.Background(), id.ZidDefaultHome, api.PartContent) if err != nil { t.Error(err) return } if value.IsNil() { Index: client/retrieve.go ================================================================== --- client/retrieve.go +++ client/retrieve.go @@ -22,10 +22,11 @@ "net/http" "t73f.de/r/sx" "t73f.de/r/sx/sxreader" "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/id" "t73f.de/r/zsc/sexp" "t73f.de/r/zsc/sz" ) var bsLF = []byte{'\n'} @@ -43,11 +44,11 @@ ub := c.NewURLBuilder('z').AppendQuery(query) resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusOK: case http.StatusNoContent: return nil, nil @@ -76,12 +77,12 @@ ub := c.NewURLBuilder('z').AppendKVQuery(api.QueryKeyEncoding, api.EncodingData).AppendQuery(query) resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return "", "", nil, err } - defer resp.Body.Close() - rdr := sxreader.MakeReader(resp.Body) + defer func() { _ = resp.Body.Close() }() + rdr := sxreader.MakeReader(resp.Body).SetListLimit(0) // No limit b/c number of zettel may be more than 100000. We must trust the server obj, err := rdr.Read() switch resp.StatusCode { case http.StatusOK: case http.StatusNoContent: return "", "", nil, nil @@ -89,11 +90,11 @@ return "", "", nil, statusToError(resp) } if err != nil { return "", "", nil, err } - vals, err := sexp.ParseList(obj, "yppp") + vals, err := sexp.ParseList(obj, "yppr") if err != nil { return "", "", nil, err } qVals, err := sexp.ParseList(vals[1], "ys") if err != nil { @@ -106,23 +107,17 @@ metaList, err := parseMetaList(vals[3].(*sx.Pair)) return sz.GoValue(qVals[1]), sz.GoValue(hVals[1]), metaList, err } func parseMetaList(metaPair *sx.Pair) ([]api.ZidMetaRights, error) { - if metaPair == nil { - return nil, fmt.Errorf("no zettel list") - } - if errSym := sexp.CheckSymbol(metaPair.Car(), "list"); errSym != nil { - return nil, errSym - } var result []api.ZidMetaRights - for node := metaPair.Cdr(); !sx.IsNil(node); { + for node := metaPair; !sx.IsNil(node); { elem, isPair := sx.GetPair(node) if !isPair { return nil, fmt.Errorf("meta-list not a proper list: %v", metaPair.String()) } - node = elem.Cdr() + node = elem.Tail() vals, err := sexp.ParseList(elem.Car(), "yppp") if err != nil { return nil, err } @@ -180,11 +175,11 @@ agg := make(api.Aggregate, len(lines)) for _, line := range lines { if fields := bytes.Fields(line); len(fields) > 1 { key := string(fields[0]) for _, field := range fields[1:] { - if zid := api.ZettelID(string(field)); zid.IsValid() { + if zid, zidErr := id.Parse(string(field)); zidErr == nil { agg[key] = append(agg[key], zid) } } } } @@ -192,47 +187,43 @@ } // TagZettel returns the identifier of the tag zettel for a given tag. // // This method only works if c.AllowRedirect(true) was called. -func (c *Client) TagZettel(ctx context.Context, tag string) (api.ZettelID, error) { +func (c *Client) TagZettel(ctx context.Context, tag string) (id.Zid, error) { return c.fetchTagOrRoleZettel(ctx, api.QueryKeyTag, tag) } // RoleZettel returns the identifier of the tag zettel for a given role. // // This method only works if c.AllowRedirect(true) was called. -func (c *Client) RoleZettel(ctx context.Context, role string) (api.ZettelID, error) { +func (c *Client) RoleZettel(ctx context.Context, role string) (id.Zid, error) { return c.fetchTagOrRoleZettel(ctx, api.QueryKeyRole, role) } -func (c *Client) fetchTagOrRoleZettel(ctx context.Context, key, val string) (api.ZettelID, error) { +func (c *Client) fetchTagOrRoleZettel(ctx context.Context, key, val string) (id.Zid, error) { if c.client.CheckRedirect == nil { panic("client does not allow to track redirect") } ub := c.NewURLBuilder('z').AppendKVQuery(key, val) resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } switch resp.StatusCode { case http.StatusNotFound: - return "", nil + return id.Invalid, nil case http.StatusFound: - zid := api.ZettelID(data) - if zid.IsValid() { - return zid, nil - } - return api.InvalidZID, nil + return id.Parse(string(data)) default: - return api.InvalidZID, statusToError(resp) + return id.Invalid, statusToError(resp) } } // GetZettel returns a zettel as a byte slice. // @@ -239,20 +230,20 @@ // part must be one of "meta", "content", or "zettel". // // The format of the byte slice is described in [Layout of a zettel]. // // [Layout of a zettel]: https://zettelstore.de/manual/h/00001006000000 -func (c *Client) GetZettel(ctx context.Context, zid api.ZettelID, part string) ([]byte, error) { +func (c *Client) GetZettel(ctx context.Context, zid id.Zid, part string) ([]byte, error) { ub := c.NewURLBuilder('z').SetZid(zid) if part != "" && part != api.PartContent { ub.AppendKVQuery(api.QueryKeyPart, part) } resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusOK: case http.StatusNoContent: return nil, nil @@ -261,17 +252,17 @@ } return data, err } // GetZettelData returns a zettel as a struct of its parts. -func (c *Client) GetZettelData(ctx context.Context, zid api.ZettelID) (api.ZettelData, error) { +func (c *Client) GetZettelData(ctx context.Context, zid id.Zid) (api.ZettelData, error) { ub := c.NewURLBuilder('z').SetZid(zid) ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) ub.AppendKVQuery(api.QueryKeyPart, api.PartZettel) resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err == nil { - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return api.ZettelData{}, statusToError(resp) } rdr := sxreader.MakeReader(resp.Body) obj, err2 := rdr.Read() @@ -288,11 +279,11 @@ // // Valid encoding values are given as constants. They are described in more // detail in [Encodings available via the API]. // // [Encodings available via the API]: https://zettelstore.de/manual/h/00001012920500 -func (c *Client) GetParsedZettel(ctx context.Context, zid api.ZettelID, enc api.EncodingEnum) ([]byte, error) { +func (c *Client) GetParsedZettel(ctx context.Context, zid id.Zid, enc api.EncodingEnum) ([]byte, error) { return c.getZettelString(ctx, zid, enc, true) } // GetEvaluatedZettel return an evaluated zettel in a specified text-based encoding. // @@ -301,15 +292,15 @@ // // Valid encoding values are given as constants. They are described in more // detail in [Encodings available via the API]. // // [Encodings available via the API]: https://zettelstore.de/manual/h/00001012920500 -func (c *Client) GetEvaluatedZettel(ctx context.Context, zid api.ZettelID, enc api.EncodingEnum) ([]byte, error) { +func (c *Client) GetEvaluatedZettel(ctx context.Context, zid id.Zid, enc api.EncodingEnum) ([]byte, error) { return c.getZettelString(ctx, zid, enc, false) } -func (c *Client) getZettelString(ctx context.Context, zid api.ZettelID, enc api.EncodingEnum, parseOnly bool) ([]byte, error) { +func (c *Client) getZettelString(ctx context.Context, zid id.Zid, enc api.EncodingEnum, parseOnly bool) ([]byte, error) { ub := c.NewURLBuilder('z').SetZid(zid) ub.AppendKVQuery(api.QueryKeyEncoding, enc.String()) ub.AppendKVQuery(api.QueryKeyPart, api.PartContent) if parseOnly { ub.AppendKVQuery(api.QueryKeyParseOnly, "") @@ -316,11 +307,11 @@ } resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK: case http.StatusNoContent: default: return nil, statusToError(resp) @@ -333,11 +324,11 @@ // A parsed zettel is just read from its box and is not processed any further. // // part must be one of "meta", "content", or "zettel". // // Basically, this function returns the sz encoding of a part of a zettel. -func (c *Client) GetParsedSz(ctx context.Context, zid api.ZettelID, part string) (sx.Object, error) { +func (c *Client) GetParsedSz(ctx context.Context, zid id.Zid, part string) (sx.Object, error) { return c.getSz(ctx, zid, part, true) } // GetEvaluatedSz returns an evaluated zettel as a Sexpr-decoded data structure. // @@ -345,15 +336,15 @@ // This is the zettel representation you typically see on the Web UI. // // part must be one of "meta", "content", or "zettel". // // Basically, this function returns the sz encoding of a part of a zettel. -func (c *Client) GetEvaluatedSz(ctx context.Context, zid api.ZettelID, part string) (sx.Object, error) { +func (c *Client) GetEvaluatedSz(ctx context.Context, zid id.Zid, part string) (sx.Object, error) { return c.getSz(ctx, zid, part, false) } -func (c *Client) getSz(ctx context.Context, zid api.ZettelID, part string, parseOnly bool) (sx.Object, error) { +func (c *Client) getSz(ctx context.Context, zid id.Zid, part string, parseOnly bool) (sx.Object, error) { ub := c.NewURLBuilder('z').SetZid(zid) ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingSz) if part != "" { ub.AppendKVQuery(api.QueryKeyPart, part) } @@ -362,27 +353,27 @@ } resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, statusToError(resp) } return sxreader.MakeReader(bufio.NewReaderSize(resp.Body, 8)).Read() } // GetMetaData returns the metadata of a zettel. -func (c *Client) GetMetaData(ctx context.Context, zid api.ZettelID) (api.MetaRights, error) { +func (c *Client) GetMetaData(ctx context.Context, zid id.Zid) (api.MetaRights, error) { ub := c.NewURLBuilder('z').SetZid(zid) ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) ub.AppendKVQuery(api.QueryKeyPart, api.PartMeta) resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return api.MetaRights{}, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() rdr := sxreader.MakeReader(resp.Body) obj, err := rdr.Read() if resp.StatusCode != http.StatusOK { return api.MetaRights{}, statusToError(resp) } @@ -417,11 +408,11 @@ func (c *Client) GetVersionInfo(ctx context.Context) (VersionInfo, error) { resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, c.NewURLBuilder('x'), nil) if err != nil { return VersionInfo{}, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return VersionInfo{}, statusToError(resp) } rdr := sxreader.MakeReader(resp.Body) obj, err := rdr.Read() @@ -461,33 +452,34 @@ Hash string } // GetApplicationZid returns the zettel identifier used to configure a client // application with the given name. -func (c *Client) GetApplicationZid(ctx context.Context, appname string) (api.ZettelID, error) { - mr, err := c.GetMetaData(ctx, api.ZidAppDirectory) +func (c *Client) GetApplicationZid(ctx context.Context, appname string) (id.Zid, error) { + mr, err := c.GetMetaData(ctx, id.ZidAppDirectory) if err != nil { - return api.InvalidZID, err + return id.Invalid, err } key := appname + "-zid" val, found := mr.Meta[key] if !found { - return api.InvalidZID, fmt.Errorf("no application registered: %v", appname) + return id.Invalid, fmt.Errorf("no application registered: %v", appname) } - if zid := api.ZettelID(val); zid.IsValid() { + zid, err := id.Parse(val) + if err == nil { return zid, nil } - return api.InvalidZID, fmt.Errorf("invalid identifier for application %v: %v", appname, val) + return id.Invalid, fmt.Errorf("invalid identifier for application %v: %v", appname, val) } // Get executes a GET request to the given URL and returns the read data. func (c *Client) Get(ctx context.Context, ub *api.URLBuilder) ([]byte, error) { resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK: case http.StatusNoContent: return nil, nil default: ADDED domain/id/id.go Index: domain/id/id.go ================================================================== --- /dev/null +++ domain/id/id.go @@ -0,0 +1,218 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package id provides zettel specific types, constants, and functions about +// zettel identifier. +package id + +import ( + "strconv" + "time" +) + +// Zid is the internal identifier of a zettel. Typically, it is a time stamp +// of the form "YYYYMMDDHHmmSS" converted to an unsigned integer. +type Zid uint64 + +// LengthZid factors the constant length of a zettel identifier +const LengthZid = 14 + +// Some important ZettelIDs. +const ( + Invalid = Zid(0) // Invalid is a Zid that will never be valid + + maxZid = 99999999999999 +) + +// Predefined zettel identifier. +// +// See [List of predefined zettel]. +// +// [List of predefined zettel]: https://zettelstore.de/manual/h/00001005090000 +const ( + // System zettel + ZidVersion = Zid(1) + ZidHost = Zid(2) + ZidOperatingSystem = Zid(3) + ZidLicense = Zid(4) + ZidAuthors = Zid(5) + ZidDependencies = Zid(6) + ZidLog = Zid(7) + ZidMemory = Zid(8) + ZidSx = Zid(9) + ZidHTTP = Zid(10) + ZidAPI = Zid(11) + ZidWebUI = Zid(12) + ZidConsole = Zid(13) + ZidBoxManager = Zid(20) + ZidZettel = Zid(21) + ZidIndex = Zid(22) + ZidQuery = Zid(23) + ZidMetadataKey = Zid(90) + ZidParser = Zid(92) + ZidStartupConfiguration = Zid(96) + ZidConfiguration = Zid(100) + ZidDirectory = Zid(101) + + // WebUI HTML templates are in the range 10000..19999 + ZidBaseTemplate = Zid(10100) + ZidLoginTemplate = Zid(10200) + ZidListTemplate = Zid(10300) + ZidZettelTemplate = Zid(10401) + ZidInfoTemplate = Zid(10402) + ZidFormTemplate = Zid(10403) + ZidDeleteTemplate = Zid(10405) + ZidErrorTemplate = Zid(10700) + + // WebUI sxn code zettel are in the range 19000..19999 + ZidSxnStart = Zid(19000) + ZidSxnBase = Zid(19990) + + // CSS-related zettel are in the range 20000..29999 + ZidBaseCSS = Zid(20001) + ZidUserCSS = Zid(25001) + + // WebUI JS zettel are in the range 30000..39999 + + // WebUI image zettel are in the range 40000..49999 + ZidEmoji = Zid(40001) + + // Other sxn code zettel are in the range 50000..59999 + ZidSxnPrelude = Zid(59900) + + // Predefined Zettelmarkup zettel are in the range 60000..69999 + ZidRoleZettelZettel = Zid(60010) + ZidRoleConfigurationZettel = Zid(60020) + ZidRoleRoleZettel = Zid(60030) + ZidRoleTagZettel = Zid(60040) + + // Range 80000...89999 is reserved for web ui menus + ZidTOCListsMenu = Zid(80001) // "Lists" menu + + // Range 90000...99999 is reserved for zettel templates + ZidTOCNewTemplate = Zid(90000) + ZidTemplateNewZettel = Zid(90001) + ZidTemplateNewRole = Zid(90004) + ZidTemplateNewTag = Zid(90003) + ZidTemplateNewUser = Zid(90002) + + // Range 00000999999900...00000999999999 are predefined zettel to be searched by content. + ZidAppDirectory = Zid(999999999) + + // Default Home Zettel + ZidDefaultHome = Zid(10000000000) +) + +// ParseUint interprets a string as a possible zettel identifier +// and returns its integer value. +func ParseUint(s string) (uint64, error) { + res, err := strconv.ParseUint(s, 10, 47) + if err != nil { + return 0, err + } + if res == 0 || res > maxZid { + return res, strconv.ErrRange + } + return res, nil +} + +// Parse interprets a string as a zettel identification and +// returns its value. +func Parse(s string) (Zid, error) { + if len(s) != LengthZid { + return Invalid, strconv.ErrSyntax + } + res, err := ParseUint(s) + if err != nil { + return Invalid, err + } + return Zid(res), nil +} + +// MustParse tries to interpret a string as a zettel identifier and returns +// its value or panics otherwise. +func MustParse(s string) Zid { + zid, err := Parse(string(s)) + if err == nil { + return zid + } + panic(err) +} + +// String converts the zettel identification to a string of 14 digits. +// Only defined for valid ids. +func (zid Zid) String() string { + var result [LengthZid]byte + zid.toByteArray(&result) + return string(result[:]) +} + +// Bytes converts the zettel identification to a byte slice of 14 digits. +// Only defined for valid ids. +func (zid Zid) Bytes() []byte { + var result [LengthZid]byte + zid.toByteArray(&result) + return result[:] +} + +// toByteArray converts the Zid into a fixed byte array, usable for printing. +// +// Based on idea by Daniel Lemire: "Converting integers to fix-digit representations quickly" +// https://lemire.me/blog/2021/11/18/converting-integers-to-fix-digit-representations-quickly/ +func (zid Zid) toByteArray(result *[LengthZid]byte) { + date := uint64(zid) / 1000000 + fullyear := date / 10000 + century, year := fullyear/100, fullyear%100 + monthday := date % 10000 + month, day := monthday/100, monthday%100 + time := uint64(zid) % 1000000 + hmtime, second := time/100, time%100 + hour, minute := hmtime/100, hmtime%100 + + result[0] = byte(century/10) + '0' + result[1] = byte(century%10) + '0' + result[2] = byte(year/10) + '0' + result[3] = byte(year%10) + '0' + result[4] = byte(month/10) + '0' + result[5] = byte(month%10) + '0' + result[6] = byte(day/10) + '0' + result[7] = byte(day%10) + '0' + result[8] = byte(hour/10) + '0' + result[9] = byte(hour%10) + '0' + result[10] = byte(minute/10) + '0' + result[11] = byte(minute%10) + '0' + result[12] = byte(second/10) + '0' + result[13] = byte(second%10) + '0' +} + +// IsValid determines if zettel id is a valid one, e.g. consists of max. 14 digits. +func (zid Zid) IsValid() bool { return 0 < zid && zid <= maxZid } + +// TimestampLayout to transform a date into a Zid and into other internal dates. +const TimestampLayout = "20060102150405" + +// New returns a new zettel id based on the current time. +func New(withSeconds bool) Zid { + now := time.Now().Local() + var s string + if withSeconds { + s = now.Format(TimestampLayout) + } else { + s = now.Format("20060102150400") + } + res, err := Parse(s) + if err != nil { + panic(err) + } + return res +} ADDED domain/id/id_test.go Index: domain/id/id_test.go ================================================================== --- /dev/null +++ domain/id/id_test.go @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package id_test provides unit tests for testing zettel id specific functions. +package id_test + +import ( + "testing" + + "t73f.de/r/zsc/domain/id" +) + +func TestIsValid(t *testing.T) { + t.Parallel() + validIDs := []string{ + "00000000000001", + "00000000000020", + "00000000000300", + "00000000004000", + "00000000050000", + "00000000600000", + "00000007000000", + "00000080000000", + "00000900000000", + "00001000000000", + "00020000000000", + "00300000000000", + "04000000000000", + "50000000000000", + "99999999999999", + "00001007030200", + "20200310195100", + "12345678901234", + } + + for i, sid := range validIDs { + zid, err := id.Parse(sid) + if err != nil { + t.Errorf("i=%d: sid=%q is not valid, but should be. err=%v", i, sid, err) + } + s := zid.String() + if s != sid { + t.Errorf( + "i=%d: zid=%v does not format to %q, but to %q", i, zid, sid, s) + } + } + + invalidIDs := []string{ + "", "0", "a", + "00000000000000", + "0000000000000a", + "000000000000000", + "20200310T195100", + "+1234567890123", + } + + for i, sid := range invalidIDs { + if zid, err := id.Parse(sid); err == nil { + t.Errorf("i=%d: sid=%q is valid (zid=%s), but should not be", i, sid, zid) + } + } +} + +var sResult string // to disable compiler optimization in loop below + +func BenchmarkString(b *testing.B) { + var s string + for b.Loop() { + s = id.Zid(12345678901200).String() + } + sResult = s +} + +var bResult []byte // to disable compiler optimization in loop below + +func BenchmarkBytes(b *testing.B) { + var bs []byte + for b.Loop() { + bs = id.Zid(12345678901200).Bytes() + } + bResult = bs +} ADDED domain/id/idgraph/digraph.go Index: domain/id/idgraph/digraph.go ================================================================== --- /dev/null +++ domain/id/idgraph/digraph.go @@ -0,0 +1,243 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package idgraph implements a graph of zettel identifier. +package idgraph + +import ( + "maps" + "slices" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/id/idset" +) + +// Digraph relates zettel identifier in a directional way. +type Digraph map[id.Zid]*idset.Set + +// AddVertex adds an edge / vertex to the digraph. +func (dg Digraph) AddVertex(zid id.Zid) Digraph { + if dg == nil { + return Digraph{zid: nil} + } + if _, found := dg[zid]; !found { + dg[zid] = nil + } + return dg +} + +// RemoveVertex removes a vertex and all its edges from the digraph. +func (dg Digraph) RemoveVertex(zid id.Zid) { + if len(dg) > 0 { + delete(dg, zid) + for vertex, closure := range dg { + dg[vertex] = closure.Remove(zid) + } + } +} + +// AddEdge adds a connection from `zid1` to `zid2`. +// Both vertices must be added before. Otherwise the function may panic. +func (dg Digraph) AddEdge(fromZid, toZid id.Zid) Digraph { + if dg == nil { + return Digraph{fromZid: (*idset.Set)(nil).Add(toZid), toZid: nil} + } + dg[fromZid] = dg[fromZid].Add(toZid) + return dg +} + +// AddEgdes adds all given `Edge`s to the digraph. +// +// In contrast to `AddEdge` the vertices must not exist before. +func (dg Digraph) AddEgdes(edges EdgeSlice) Digraph { + if dg == nil { + if len(edges) == 0 { + return nil + } + dg = make(Digraph, len(edges)) + } + for _, edge := range edges { + dg = dg.AddVertex(edge.From) + dg = dg.AddVertex(edge.To) + dg = dg.AddEdge(edge.From, edge.To) + } + return dg +} + +// Equal returns true if both digraphs have the same vertices and edges. +func (dg Digraph) Equal(other Digraph) bool { + return maps.EqualFunc(dg, other, func(cg, co *idset.Set) bool { return cg.Equal(co) }) +} + +// Clone a digraph. +func (dg Digraph) Clone() Digraph { + if len(dg) == 0 { + return nil + } + copyDG := make(Digraph, len(dg)) + for vertex, closure := range dg { + copyDG[vertex] = closure.Clone() + } + return copyDG +} + +// HasVertex returns true, if `zid` is a vertex of the digraph. +func (dg Digraph) HasVertex(zid id.Zid) bool { + if len(dg) == 0 { + return false + } + _, found := dg[zid] + return found +} + +// Vertices returns the set of all vertices. +func (dg Digraph) Vertices() *idset.Set { + if len(dg) == 0 { + return nil + } + verts := idset.NewCap(len(dg)) + for vert := range dg { + verts.Add(vert) + } + return verts +} + +// Edges returns an unsorted slice of the edges of the digraph. +func (dg Digraph) Edges() (es EdgeSlice) { + for vert, closure := range dg { + closure.ForEach(func(next id.Zid) { + es = append(es, Edge{From: vert, To: next}) + }) + } + return es +} + +// Originators will return the set of all vertices that are not referenced +// a the to-part of an edge. +func (dg Digraph) Originators() *idset.Set { + if len(dg) == 0 { + return nil + } + origs := dg.Vertices() + for _, closure := range dg { + origs.ISubstract(closure) + } + return origs +} + +// Terminators returns the set of all vertices that does not reference +// other vertices. +func (dg Digraph) Terminators() (terms *idset.Set) { + for vert, closure := range dg { + if closure.IsEmpty() { + terms = terms.Add(vert) + } + } + return terms +} + +// TransitiveClosure calculates the sub-graph that is reachable from `zid`. +func (dg Digraph) TransitiveClosure(zid id.Zid) (tc Digraph) { + if len(dg) == 0 { + return nil + } + var marked *idset.Set + stack := []id.Zid{zid} + for pos := len(stack) - 1; pos >= 0; pos = len(stack) - 1 { + curr := stack[pos] + stack = stack[:pos] + if marked.Contains(curr) { + continue + } + tc = tc.AddVertex(curr) + dg[curr].ForEach(func(next id.Zid) { + tc = tc.AddVertex(next) + tc = tc.AddEdge(curr, next) + stack = append(stack, next) + }) + marked = marked.Add(curr) + } + return tc +} + +// ReachableVertices calculates the set of all vertices that are reachable +// from the given `zid`. +func (dg Digraph) ReachableVertices(zid id.Zid) (tc *idset.Set) { + if len(dg) == 0 { + return nil + } + stack := dg[zid].SafeSorted() + for last := len(stack) - 1; last >= 0; last = len(stack) - 1 { + curr := stack[last] + stack = stack[:last] + if tc.Contains(curr) { + continue + } + closure, found := dg[curr] + if !found { + continue + } + tc = tc.Add(curr) + closure.ForEach(func(next id.Zid) { + stack = append(stack, next) + }) + } + return tc +} + +// IsDAG returns a vertex and false, if the graph has a cycle containing the vertex. +func (dg Digraph) IsDAG() (id.Zid, bool) { + for vertex := range dg { + if dg.ReachableVertices(vertex).Contains(vertex) { + return vertex, false + } + } + return id.Invalid, true +} + +// Reverse returns a graph with reversed edges. +func (dg Digraph) Reverse() (revDg Digraph) { + for vertex, closure := range dg { + revDg = revDg.AddVertex(vertex) + closure.ForEach(func(next id.Zid) { + revDg = revDg.AddVertex(next) + revDg = revDg.AddEdge(next, vertex) + }) + } + return revDg +} + +// SortReverse returns a deterministic, topological, reverse sort of the +// digraph. +// +// Works only if digraph is a DAG. Otherwise the algorithm will not terminate +// or returns an arbitrary value. +func (dg Digraph) SortReverse() (sl []id.Zid) { + if len(dg) == 0 { + return nil + } + tempDg := dg.Clone() + for len(tempDg) > 0 { + terms := tempDg.Terminators() + if terms.IsEmpty() { + break + } + termSlice := terms.SafeSorted() + slices.Reverse(termSlice) + sl = append(sl, termSlice...) + terms.ForEach(func(t id.Zid) { + tempDg.RemoveVertex(t) + }) + } + return sl +} ADDED domain/id/idgraph/digraph_test.go Index: domain/id/idgraph/digraph_test.go ================================================================== --- /dev/null +++ domain/id/idgraph/digraph_test.go @@ -0,0 +1,181 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +package idgraph_test + +import ( + "slices" + "testing" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/id/idgraph" + "t73f.de/r/zsc/domain/id/idset" +) + +type zps = idgraph.EdgeSlice + +func createDigraph(pairs zps) (dg idgraph.Digraph) { + return dg.AddEgdes(pairs) +} + +func TestDigraphOriginators(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + dg idgraph.EdgeSlice + orig *idset.Set + term *idset.Set + }{ + {"empty", nil, nil, nil}, + {"single", zps{{0, 1}}, idset.New(0), idset.New(1)}, + {"chain", zps{{0, 1}, {1, 2}, {2, 3}}, idset.New(0), idset.New(3)}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + dg := createDigraph(tc.dg) + if got := dg.Originators(); !tc.orig.Equal(got) { + t.Errorf("Originators: expected:\n%v, but got:\n%v", tc.orig, got) + } + if got := dg.Terminators(); !tc.term.Equal(got) { + t.Errorf("Termintors: expected:\n%v, but got:\n%v", tc.orig, got) + } + }) + } +} + +func TestDigraphReachableVertices(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + pairs idgraph.EdgeSlice + start id.Zid + exp *idset.Set + }{ + {"nil", nil, 0, nil}, + {"0-2", zps{{1, 2}, {2, 3}}, 1, idset.New(2, 3)}, + {"1,2", zps{{1, 2}, {2, 3}}, 2, idset.New(3)}, + {"0-2,1-2", zps{{1, 2}, {2, 3}, {1, 3}}, 1, idset.New(2, 3)}, + {"0-2,1-2/1", zps{{1, 2}, {2, 3}, {1, 3}}, 2, idset.New(3)}, + {"0-2,1-2/2", zps{{1, 2}, {2, 3}, {1, 3}}, 3, nil}, + {"0-2,1-2,3*", zps{{1, 2}, {2, 3}, {1, 3}, {4, 4}}, 1, idset.New(2, 3)}, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + dg := createDigraph(tc.pairs) + if got := dg.ReachableVertices(tc.start); !got.Equal(tc.exp) { + t.Errorf("\n%v, but got:\n%v", tc.exp, got) + } + + }) + } +} + +func TestDigraphTransitiveClosure(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + pairs idgraph.EdgeSlice + start id.Zid + exp idgraph.EdgeSlice + }{ + {"nil", nil, 0, nil}, + {"1-3", zps{{1, 2}, {2, 3}}, 1, zps{{1, 2}, {2, 3}}}, + {"1,2", zps{{1, 1}, {2, 3}}, 2, zps{{2, 3}}}, + {"0-2,1-2", zps{{1, 2}, {2, 3}, {1, 3}}, 1, zps{{1, 2}, {1, 3}, {2, 3}}}, + {"0-2,1-2/1", zps{{1, 2}, {2, 3}, {1, 3}}, 1, zps{{1, 2}, {1, 3}, {2, 3}}}, + {"0-2,1-2/2", zps{{1, 2}, {2, 3}, {1, 3}}, 2, zps{{2, 3}}}, + {"0-2,1-2,3*", zps{{1, 2}, {2, 3}, {1, 3}, {4, 4}}, 1, zps{{1, 2}, {1, 3}, {2, 3}}}, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + dg := createDigraph(tc.pairs) + if got := dg.TransitiveClosure(tc.start).Edges().Sort(); !got.Equal(tc.exp) { + t.Errorf("\n%v, but got:\n%v", tc.exp, got) + } + }) + } +} + +func TestIsDAG(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + dg idgraph.EdgeSlice + exp bool + }{ + {"empty", nil, true}, + {"single-edge", zps{{1, 2}}, true}, + {"single-loop", zps{{1, 1}}, false}, + {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, false}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if zid, got := createDigraph(tc.dg).IsDAG(); got != tc.exp { + t.Errorf("expected %v, but got %v (%v)", tc.exp, got, zid) + } + }) + } +} + +func TestDigraphReverse(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + dg idgraph.EdgeSlice + exp idgraph.EdgeSlice + }{ + {"empty", nil, nil}, + {"single-edge", zps{{1, 2}}, zps{{2, 1}}}, + {"single-loop", zps{{1, 1}}, zps{{1, 1}}}, + {"end-loop", zps{{1, 2}, {2, 2}}, zps{{2, 1}, {2, 2}}}, + {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, zps{{2, 1}, {2, 5}, {3, 2}, {4, 3}, {5, 4}}}, + {"sect-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 2}}, zps{{2, 1}, {2, 4}, {3, 2}, {4, 3}, {5, 4}}}, + {"two-islands", zps{{1, 2}, {2, 3}, {4, 5}}, zps{{2, 1}, {3, 2}, {5, 4}}}, + {"direct-indirect", zps{{1, 2}, {1, 3}, {3, 2}}, zps{{2, 1}, {2, 3}, {3, 1}}}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + dg := createDigraph(tc.dg) + if got := dg.Reverse().Edges().Sort(); !got.Equal(tc.exp) { + t.Errorf("\n%v, but got:\n%v", tc.exp, got) + } + }) + } +} + +func TestDigraphSortReverse(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + dg idgraph.EdgeSlice + exp []id.Zid + }{ + {"empty", nil, nil}, + {"single-edge", zps{{1, 2}}, []id.Zid{2, 1}}, + {"single-loop", zps{{1, 1}}, nil}, + {"end-loop", zps{{1, 2}, {2, 2}}, []id.Zid{}}, + {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, []id.Zid{}}, + {"sect-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 2}}, []id.Zid{5}}, + {"two-islands", zps{{1, 2}, {2, 3}, {4, 5}}, []id.Zid{5, 3, 4, 2, 1}}, + {"direct-indirect", zps{{1, 2}, {1, 3}, {3, 2}}, []id.Zid{2, 3, 1}}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if got := createDigraph(tc.dg).SortReverse(); !slices.Equal(got, tc.exp) { + t.Errorf("expected:\n%v, but got:\n%v", tc.exp, got) + } + }) + } +} ADDED domain/id/idgraph/edge.go Index: domain/id/idgraph/edge.go ================================================================== --- /dev/null +++ domain/id/idgraph/edge.go @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of Zettelstore. +// +// Zettelstore is licensed under the latest version of the EUPL (European Union +// Public License). Please see file LICENSE.txt for your rights and obligations +// under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +package idgraph + +import ( + "slices" + + "t73f.de/r/zsc/domain/id" +) + +// Edge is a pair of to vertices. +type Edge struct { + From, To id.Zid +} + +// EdgeSlice is a slice of Edges +type EdgeSlice []Edge + +// Equal return true if both slices are the same. +func (es EdgeSlice) Equal(other EdgeSlice) bool { + return slices.Equal(es, other) +} + +// Sort the slice. +func (es EdgeSlice) Sort() EdgeSlice { + slices.SortFunc(es, func(e1, e2 Edge) int { + if e1.From < e2.From { + return -1 + } + if e1.From > e2.From { + return 1 + } + if e1.To < e2.To { + return -1 + } + if e1.To > e2.To { + return 1 + } + return 0 + }) + return es +} ADDED domain/id/idset/idset.go Index: domain/id/idset/idset.go ================================================================== --- /dev/null +++ domain/id/idset/idset.go @@ -0,0 +1,325 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2021-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2021-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package idset implements sets of zettel identifier. +package idset + +import ( + "slices" + "strings" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +// Set is a set of zettel identifier +type Set struct { + seq []id.Zid +} + +// String returns a string representation of the set. +func (s *Set) String() string { + return "{" + s.MetaString() + "}" +} + +// MetaString returns a string representation of the set to be stored as metadata. +func (s *Set) MetaString() string { + if s == nil || len(s.seq) == 0 { + return "" + } + var sb strings.Builder + for i, zid := range s.seq { + if i > 0 { + sb.WriteByte(' ') + } + sb.Write(zid.Bytes()) + } + return sb.String() +} + +// MetaValue returns a metadata value representation of the set. +func (s *Set) MetaValue() meta.Value { return meta.Value(s.MetaString()) } + +// New returns a new set of identifier with the given initial values. +func New(zids ...id.Zid) *Set { + switch l := len(zids); l { + case 0: + return &Set{seq: nil} + case 1: + return &Set{seq: []id.Zid{zids[0]}} + default: + result := Set{seq: make([]id.Zid, 0, l)} + result.AddSlice(zids) + return &result + } +} + +// NewCap returns a new set of identifier with the given capacity and initial values. +func NewCap(c int, zids ...id.Zid) *Set { + result := Set{seq: make([]id.Zid, 0, max(c, len(zids)))} + result.AddSlice(zids) + return &result +} + +// IsEmpty returns true, if the set conains no element. +func (s *Set) IsEmpty() bool { + return s == nil || len(s.seq) == 0 +} + +// Length returns the number of elements in this set. +func (s *Set) Length() int { + if s == nil { + return 0 + } + return len(s.seq) +} + +// Clone returns a copy of the given set. +func (s *Set) Clone() *Set { + if s == nil || len(s.seq) == 0 { + return nil + } + return &Set{seq: slices.Clone(s.seq)} +} + +// Add adds a Add to the set. +func (s *Set) Add(zid id.Zid) *Set { + if s == nil { + return New(zid) + } + s.add(zid) + return s +} + +// Contains return true if the set is non-nil and the set contains the given Zettel identifier. +func (s *Set) Contains(zid id.Zid) bool { return s != nil && s.contains(zid) } + +// ContainsOrNil return true if the set is nil or if the set contains the given Zettel identifier. +func (s *Set) ContainsOrNil(zid id.Zid) bool { return s == nil || s.contains(zid) } + +// AddSlice adds all identifier of the given slice to the set. +func (s *Set) AddSlice(sl []id.Zid) *Set { + if s == nil { + return New(sl...) + } + s.seq = slices.Grow(s.seq, len(sl)) + for _, zid := range sl { + s.add(zid) + } + return s +} + +// SafeSorted returns the set as a new sorted slice of zettel identifier. +func (s *Set) SafeSorted() []id.Zid { + if s == nil { + return nil + } + return slices.Clone(s.seq) +} + +// IntersectOrSet removes all zettel identifier that are not in the other set. +// Both sets can be modified by this method. One of them is the set returned. +// It contains the intersection of both, if s is not nil. +// +// If s == nil, then the other set is always returned. +func (s *Set) IntersectOrSet(other *Set) *Set { + if s == nil || other == nil { + return other.Clone() + } + topos, spos, opos := 0, 0, 0 + for spos < len(s.seq) && opos < len(other.seq) { + sz, oz := s.seq[spos], other.seq[opos] + if sz < oz { + spos++ + continue + } + if sz > oz { + opos++ + continue + } + s.seq[topos] = sz + topos++ + spos++ + opos++ + } + s.seq = s.seq[:topos] + return s +} + +// IUnion adds the elements of set other to s. +func (s *Set) IUnion(other *Set) *Set { + if other == nil || len(other.seq) == 0 { + return s + } + // TODO: if other is large enough (and s is not too small) -> optimize by swapping and/or loop through both + return s.AddSlice(other.seq) +} + +// ISubstract removes all zettel identifier from 's' that are in the set 'other'. +func (s *Set) ISubstract(other *Set) { + if s == nil || len(s.seq) == 0 || other == nil || len(other.seq) == 0 { + return + } + topos, spos, opos := 0, 0, 0 + for spos < len(s.seq) && opos < len(other.seq) { + sz, oz := s.seq[spos], other.seq[opos] + if sz < oz { + s.seq[topos] = sz + topos++ + spos++ + continue + } + if sz == oz { + spos++ + } + opos++ + } + for spos < len(s.seq) { + s.seq[topos] = s.seq[spos] + topos++ + spos++ + } + s.seq = s.seq[:topos] +} + +// Diff returns the difference sets between the two sets: the first difference +// set is the set of elements that are in other, but not in s; the second +// difference set is the set of element that are in s but not in other. +// +// in other words: the first result is the set of elements from other that must +// be added to s; the second result is the set of elements that must be removed +// from s, so that s would have the same elemest as other. +func (s *Set) Diff(other *Set) (newS, remS *Set) { + if s == nil || len(s.seq) == 0 { + return other.Clone(), nil + } + if other == nil || len(other.seq) == 0 { + return nil, s.Clone() + } + seqS, seqO := s.seq, other.seq + var newRefs, remRefs []id.Zid + npos, opos := 0, 0 + for npos < len(seqO) && opos < len(seqS) { + rn, ro := seqO[npos], seqS[opos] + if rn == ro { + npos++ + opos++ + continue + } + if rn < ro { + newRefs = append(newRefs, rn) + npos++ + continue + } + remRefs = append(remRefs, ro) + opos++ + } + if npos < len(seqO) { + newRefs = append(newRefs, seqO[npos:]...) + } + if opos < len(seqS) { + remRefs = append(remRefs, seqS[opos:]...) + } + return newFromSlice(newRefs), newFromSlice(remRefs) +} + +// Remove the identifier from the set. +func (s *Set) Remove(zid id.Zid) *Set { + if s == nil || len(s.seq) == 0 { + return nil + } + if pos, found := s.find(zid); found { + copy(s.seq[pos:], s.seq[pos+1:]) + s.seq = s.seq[:len(s.seq)-1] + } + if len(s.seq) == 0 { + return nil + } + return s +} + +// Equal returns true if the other set is equal to the given set. +func (s *Set) Equal(other *Set) bool { + if s == nil { + return other == nil + } + if other == nil { + return false + } + return slices.Equal(s.seq, other.seq) +} + +// ForEach calls the given function for each element of the set. +// +// Every element is bigger than the previous one. +func (s *Set) ForEach(fn func(zid id.Zid)) { + if s != nil { + for _, zid := range s.seq { + fn(zid) + } + } +} + +// Pop return one arbitrary element of the set. +func (s *Set) Pop() (id.Zid, bool) { + if s != nil { + if l := len(s.seq); l > 0 { + zid := s.seq[l-1] + s.seq = s.seq[:l-1] + return zid, true + } + } + return id.Invalid, false +} + +// Optimize the amount of memory to store the set. +func (s *Set) Optimize() { + if s != nil { + s.seq = slices.Clip(s.seq) + } +} + +// ----- unchecked base operations + +func newFromSlice(seq []id.Zid) *Set { + if l := len(seq); l == 0 { + return nil + } + return &Set{seq: seq} +} + +func (s *Set) add(zid id.Zid) { + if pos, found := s.find(zid); !found { + s.seq = slices.Insert(s.seq, pos, zid) + } +} + +func (s *Set) contains(zid id.Zid) bool { + _, found := s.find(zid) + return found +} + +func (s *Set) find(zid id.Zid) (int, bool) { + hi := len(s.seq) + for lo := 0; lo < hi; { + m := lo + (hi-lo)/2 + if z := s.seq[m]; z == zid { + return m, true + } else if z < zid { + lo = m + 1 + } else { + hi = m + } + } + return hi, false +} ADDED domain/id/idset/idset_test.go Index: domain/id/idset/idset_test.go ================================================================== --- /dev/null +++ domain/id/idset/idset_test.go @@ -0,0 +1,241 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2021-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2021-present Detlef Stern +//----------------------------------------------------------------------------- + +package idset_test + +import ( + "slices" + "testing" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/id/idset" +) + +func TestSetContainsOrNil(t *testing.T) { + t.Parallel() + testcases := []struct { + s *idset.Set + zid id.Zid + exp bool + }{ + {nil, id.Invalid, true}, + {nil, 14, true}, + {idset.New(), id.Invalid, false}, + {idset.New(), 1, false}, + {idset.New(), id.Invalid, false}, + {idset.New(1), 1, true}, + } + for i, tc := range testcases { + got := tc.s.ContainsOrNil(tc.zid) + if got != tc.exp { + t.Errorf("%d: %v.ContainsOrNil(%v) == %v, but got %v", i, tc.s, tc.zid, tc.exp, got) + } + } +} + +func TestSetAdd(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {idset.New(), idset.New(), nil}, + {nil, idset.New(1), []id.Zid{1}}, + {idset.New(1), nil, []id.Zid{1}}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(1), idset.New(2), []id.Zid{1, 2}}, + {idset.New(1), idset.New(1), []id.Zid{1}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := tc.s1.IUnion(tc.s2).SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.Add(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetSafeSorted(t *testing.T) { + t.Parallel() + testcases := []struct { + set *idset.Set + exp []id.Zid + }{ + {nil, nil}, + {idset.New(), nil}, + {idset.New(9, 4, 6, 1, 7), []id.Zid{1, 4, 6, 7, 9}}, + } + for i, tc := range testcases { + got := tc.set.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.SafeSorted() should be %v, but got %v", i, tc.set, tc.exp, got) + } + } +} + +func TestSetIntersectOrSet(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, nil}, + {nil, idset.New(1), []id.Zid{1}}, + {idset.New(1), idset.New(), nil}, + {idset.New(), idset.New(1), nil}, + {idset.New(1), idset.New(2), nil}, + {idset.New(2), idset.New(1), nil}, + {idset.New(1), idset.New(1), []id.Zid{1}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := tc.s1.IntersectOrSet(tc.s2).SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.IntersectOrSet(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetIUnion(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp *idset.Set + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, idset.New(1)}, + {nil, idset.New(1), idset.New(1)}, + {idset.New(1), idset.New(), idset.New(1)}, + {idset.New(), idset.New(1), idset.New(1)}, + {idset.New(1), idset.New(2), idset.New(1, 2)}, + {idset.New(2), idset.New(1), idset.New(2, 1)}, + {idset.New(1), idset.New(1), idset.New(1)}, + {idset.New(1, 2, 3), idset.New(2, 3, 4), idset.New(1, 2, 3, 4)}, + } + for i, tc := range testcases { + s1 := tc.s1.Clone() + sl1 := s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := s1.IUnion(tc.s2) + if !got.Equal(tc.exp) { + t.Errorf("%d: %v.IUnion(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetISubtract(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, []id.Zid{1}}, + {nil, idset.New(1), nil}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(), idset.New(1), nil}, + {idset.New(1), idset.New(2), []id.Zid{1}}, + {idset.New(2), idset.New(1), []id.Zid{2}}, + {idset.New(1), idset.New(1), nil}, + {idset.New(1, 2, 3), idset.New(1), []id.Zid{2, 3}}, + {idset.New(1, 2, 3), idset.New(2), []id.Zid{1, 3}}, + {idset.New(1, 2, 3), idset.New(3), []id.Zid{1, 2}}, + {idset.New(1, 2, 3), idset.New(1, 2), []id.Zid{3}}, + {idset.New(1, 2, 3), idset.New(1, 3), []id.Zid{2}}, + {idset.New(1, 2, 3), idset.New(2, 3), []id.Zid{1}}, + } + for i, tc := range testcases { + s1 := tc.s1.Clone() + sl1 := s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + s1.ISubstract(tc.s2) + got := s1.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.ISubstract(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetDiff(t *testing.T) { + t.Parallel() + testcases := []struct { + in1, in2 *idset.Set + exp1, exp2 *idset.Set + }{ + {nil, nil, nil, nil}, + {idset.New(1), nil, nil, idset.New(1)}, + {nil, idset.New(1), idset.New(1), nil}, + {idset.New(1), idset.New(1), nil, nil}, + {idset.New(1, 2), idset.New(1), nil, idset.New(2)}, + {idset.New(1), idset.New(1, 2), idset.New(2), nil}, + {idset.New(1, 2), idset.New(1, 3), idset.New(3), idset.New(2)}, + {idset.New(1, 2, 3), idset.New(2, 3, 4), idset.New(4), idset.New(1)}, + {idset.New(2, 3, 4), idset.New(1, 2, 3), idset.New(1), idset.New(4)}, + } + for i, tc := range testcases { + gotN, gotO := tc.in1.Diff(tc.in2) + if !tc.exp1.Equal(gotN) { + t.Errorf("%d: expected %v, but got: %v", i, tc.exp1, gotN) + } + if !tc.exp2.Equal(gotO) { + t.Errorf("%d: expected %v, but got: %v", i, tc.exp2, gotO) + } + } +} + +func TestSetRemove(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, []id.Zid{1}}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(1), idset.New(2), []id.Zid{1}}, + {idset.New(1), idset.New(1), []id.Zid{}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + newS1 := idset.New(sl1...) + newS1.ISubstract(tc.s2) + got := newS1.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.Remove(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func BenchmarkSet(b *testing.B) { + s := idset.NewCap(b.N) + for i := range b.N { + s.Add(id.Zid(i)) + } +} ADDED domain/meta/collection.go Index: domain/meta/collection.go ================================================================== --- /dev/null +++ domain/meta/collection.go @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2022-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2022-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "slices" + "strings" +) + +// Arrangement stores metadata within its categories. +// Typecally a category might be a tag name, a role name, a syntax value. +type Arrangement map[string][]*Meta + +// CreateArrangement by inspecting a given key and use the found +// value as a category. +func CreateArrangement(metaList []*Meta, key string) Arrangement { + if len(metaList) == 0 { + return nil + } + descr := Type(key) + if descr == nil { + return nil + } + if descr.IsSet { + return createSetArrangement(metaList, key) + } + return createSimplearrangement(metaList, key) +} + +func createSetArrangement(metaList []*Meta, key string) Arrangement { + a := make(Arrangement) + for _, m := range metaList { + for val := range m.GetFields(key) { + a[val] = append(a[val], m) + } + } + return a +} + +func createSimplearrangement(metaList []*Meta, key string) Arrangement { + a := make(Arrangement) + for _, m := range metaList { + if val, ok := m.Get(key); ok && val != "" { + a[string(val)] = append(a[string(val)], m) + } + } + return a +} + +// Counted returns the list of categories, together with the number of +// metadata for each category. +func (a Arrangement) Counted() CountedCategories { + if len(a) == 0 { + return nil + } + result := make(CountedCategories, 0, len(a)) + for cat, metas := range a { + result = append(result, CountedCategory{Name: cat, Count: len(metas)}) + } + return result +} + +// CountedCategory contains of a name and the number how much this name occured +// somewhere. +type CountedCategory struct { + Name string + Count int +} + +// CountedCategories is the list of CountedCategories. +// Every name must occur only once. +type CountedCategories []CountedCategory + +// SortByName sorts the list by the name attribute. +// Since each name must occur only once, two CountedCategories cannot have +// the same name. +func (ccs CountedCategories) SortByName() { + slices.SortFunc(ccs, func(i, j CountedCategory) int { return strings.Compare(i.Name, j.Name) }) +} + +// SortByCount sorts the list by the count attribute, descending. +// If two counts are equal, elements are sorted by name. +func (ccs CountedCategories) SortByCount() { + slices.SortFunc(ccs, func(i, j CountedCategory) int { + iCount, jCount := i.Count, j.Count + if iCount > jCount { + return -1 + } + if iCount == jCount { + return strings.Compare(i.Name, j.Name) + } + return 1 + }) +} + +// Categories returns just the category names. +func (ccs CountedCategories) Categories() []string { + result := make([]string, len(ccs)) + for i, cc := range ccs { + result[i] = cc.Name + } + return result +} ADDED domain/meta/meta.go Index: domain/meta/meta.go ================================================================== --- /dev/null +++ domain/meta/meta.go @@ -0,0 +1,458 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package meta provides the zettel specific type 'meta'. +package meta + +import ( + "iter" + "maps" + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "t73f.de/r/zero/set" + "t73f.de/r/zsc/domain/id" +) + +type keyUsage int + +const ( + _ keyUsage = iota + usageUser // Key will be manipulated by the user + usageComputed // Key is computed by zettelstore + usageProperty // Key is computed and not stored by zettelstore +) + +// DescriptionKey formally describes each supported metadata key. +type DescriptionKey struct { + Name string + Type *DescriptionType + usage keyUsage + Inverse string +} + +// IsComputed returns true, if metadata is computed and not set by the user. +func (kd *DescriptionKey) IsComputed() bool { return kd.usage >= usageComputed } + +// IsProperty returns true, if metadata is a computed property. +func (kd *DescriptionKey) IsProperty() bool { return kd.usage >= usageProperty } + +var registeredKeys = make(map[string]*DescriptionKey) + +func registerKey(name string, t *DescriptionType, usage keyUsage, inverse string) { + if _, ok := registeredKeys[name]; ok { + panic("Key '" + name + "' already defined") + } + if inverse != "" { + if t != TypeID && t != TypeIDSet { + panic("Inversable key '" + name + "' is not identifier type, but " + t.String()) + } + inv, ok := registeredKeys[inverse] + if !ok { + panic("Inverse Key '" + inverse + "' not found") + } + if !inv.IsComputed() { + panic("Inverse Key '" + inverse + "' is not computed.") + } + if inv.Type != TypeIDSet { + panic("Inverse Key '" + inverse + "' is not an identifier set, but " + inv.Type.String()) + } + } + registeredKeys[name] = &DescriptionKey{name, t, usage, inverse} +} + +// IsComputed returns true, if key denotes a computed metadata key. +func IsComputed(name string) bool { + if kd, ok := registeredKeys[name]; ok { + return kd.IsComputed() + } + return false +} + +// IsProperty returns true, if key denotes a property metadata value. +func IsProperty(name string) bool { + if kd, ok := registeredKeys[name]; ok { + return kd.IsProperty() + } + return false +} + +// Inverse returns the name of the inverse key. +func Inverse(name string) string { + if kd, ok := registeredKeys[name]; ok { + return kd.Inverse + } + return "" +} + +// GetDescription returns the key description object of the given key name. +func GetDescription(name string) DescriptionKey { + if d, ok := registeredKeys[name]; ok { + return *d + } + return DescriptionKey{Type: Type(name)} +} + +// GetSortedKeyDescriptions delivers all metadata key descriptions as a slice, sorted by name. +func GetSortedKeyDescriptions() []*DescriptionKey { + keys := slices.Sorted(maps.Keys(registeredKeys)) + result := make([]*DescriptionKey, 0, len(keys)) + for _, n := range keys { + result = append(result, registeredKeys[n]) + } + return result +} + +// Key is the type of metadata keys. +type Key = string + +// Predefined / supported metadata keys. +// +// See [Supported Metadata Keys]. +// +// [Supported Metadata Keys]: https://zettelstore.de/manual/h/00001006020000 +const ( + KeyID = "id" + KeyTitle = "title" + KeyRole = "role" + KeyTags = "tags" + KeySyntax = "syntax" + KeyAuthor = "author" + KeyBack = "back" + KeyBackward = "backward" + KeyBoxNumber = "box-number" + KeyCopyright = "copyright" + KeyCreated = "created" + KeyCredential = "credential" + KeyDead = "dead" + KeyExpire = "expire" + KeyFolge = "folge" + KeyFolgeRole = "folge-role" + KeyForward = "forward" + KeyLang = "lang" + KeyLicense = "license" + KeyModified = "modified" + KeyPrecursor = "precursor" + KeyPredecessor = "predecessor" + KeyPrequel = "prequel" + KeyPublished = "published" + KeyQuery = "query" + KeyReadOnly = "read-only" + KeySequel = "sequel" + KeySubordinates = "subordinates" + KeySuccessors = "successors" + KeySummary = "summary" + KeySuperior = "superior" + KeyURL = "url" + KeyUselessFiles = "useless-files" + KeyUserID = "user-id" + KeyUserRole = "user-role" + KeyVisibility = "visibility" +) + +// Supported keys. +func init() { + registerKey(KeyID, TypeID, usageComputed, "") + registerKey(KeyTitle, TypeEmpty, usageUser, "") + registerKey(KeyRole, TypeWord, usageUser, "") + registerKey(KeyTags, TypeTagSet, usageUser, "") + registerKey(KeySyntax, TypeWord, usageUser, "") + + // Properties that are inverse keys + registerKey(KeyFolge, TypeIDSet, usageProperty, "") + registerKey(KeySequel, TypeIDSet, usageProperty, "") + registerKey(KeySuccessors, TypeIDSet, usageProperty, "") + registerKey(KeySubordinates, TypeIDSet, usageProperty, "") + + // Non-inverse keys + registerKey(KeyAuthor, TypeString, usageUser, "") + registerKey(KeyBack, TypeIDSet, usageProperty, "") + registerKey(KeyBackward, TypeIDSet, usageProperty, "") + registerKey(KeyBoxNumber, TypeNumber, usageProperty, "") + registerKey(KeyCopyright, TypeString, usageUser, "") + registerKey(KeyCreated, TypeTimestamp, usageComputed, "") + registerKey(KeyCredential, TypeCredential, usageUser, "") + registerKey(KeyDead, TypeIDSet, usageProperty, "") + registerKey(KeyExpire, TypeTimestamp, usageUser, "") + registerKey(KeyFolgeRole, TypeWord, usageUser, "") + registerKey(KeyForward, TypeIDSet, usageProperty, "") + registerKey(KeyLang, TypeWord, usageUser, "") + registerKey(KeyLicense, TypeEmpty, usageUser, "") + registerKey(KeyModified, TypeTimestamp, usageComputed, "") + registerKey(KeyPrecursor, TypeIDSet, usageUser, KeyFolge) + registerKey(KeyPredecessor, TypeID, usageUser, KeySuccessors) + registerKey(KeyPrequel, TypeIDSet, usageUser, KeySequel) + registerKey(KeyPublished, TypeTimestamp, usageProperty, "") + registerKey(KeyQuery, TypeEmpty, usageUser, "") + registerKey(KeyReadOnly, TypeWord, usageUser, "") + registerKey(KeySummary, TypeString, usageUser, "") + registerKey(KeySuperior, TypeIDSet, usageUser, KeySubordinates) + registerKey(KeyURL, TypeURL, usageUser, "") + registerKey(KeyUselessFiles, TypeString, usageProperty, "") + registerKey(KeyUserID, TypeWord, usageUser, "") + registerKey(KeyUserRole, TypeWord, usageUser, "") + registerKey(KeyVisibility, TypeWord, usageUser, "") +} + +// NewPrefix is the prefix for metadata keys in template zettel for creating new zettel. +const NewPrefix = "new-" + +// Meta contains all meta-data of a zettel. +type Meta struct { + Zid id.Zid + pairs map[Key]Value + YamlSep bool +} + +// New creates a new chunk for storing metadata. +func New(zid id.Zid) *Meta { + return &Meta{Zid: zid, pairs: make(map[Key]Value, 5)} +} + +// NewWithData creates metadata object with given data. +func NewWithData(zid id.Zid, data map[string]string) *Meta { + pairs := make(map[Key]Value, len(data)) + for k, v := range data { + pairs[k] = Value(v) + } + return &Meta{Zid: zid, pairs: pairs} +} + +// ByteSize returns the number of bytes stored for the metadata. +func (m *Meta) ByteSize() int { + if m == nil { + return 0 + } + result := 6 // storage needed for Zid + for k, v := range m.pairs { + result += len(k) + len(v) + 1 // 1 because separator + } + return result +} + +// Clone returns a new copy of the metadata. +func (m *Meta) Clone() *Meta { + return &Meta{ + Zid: m.Zid, + pairs: maps.Clone(m.pairs), + YamlSep: m.YamlSep, + } +} + +// Map returns a copy of the meta data as a string map. +func (m *Meta) Map() map[string]string { + pairs := make(map[string]string, len(m.pairs)) + for k, v := range m.pairs { + pairs[k] = string(v) + } + return pairs +} + +var reKey = regexp.MustCompile("^[0-9a-z][-0-9a-z]{0,254}$") + +// KeyIsValid returns true, if the string is a valid metadata key. +func KeyIsValid(s string) bool { return reKey.MatchString(s) } + +var firstKeys = []string{KeyTitle, KeyRole, KeyTags, KeySyntax} + +// Set stores the given string value under the given key. +func (m *Meta) Set(key string, value Value) { + if key != KeyID { + m.pairs[key] = value.TrimSpace() + } +} + +// SetNonEmpty stores the given value under the given key, if the value is non-empty. +// An empty value will delete the previous association. +func (m *Meta) SetNonEmpty(key string, value Value) { + if value == "" { + delete(m.pairs, key) // TODO: key != KeyID + } else { + m.Set(key, value.TrimSpace()) + } +} + +// Get retrieves the string value of a given key. The bool value signals, +// whether there was a value stored or not. +func (m *Meta) Get(key string) (Value, bool) { + if m == nil { + return "", false + } + if key == KeyID { + return Value(m.Zid.String()), true + } + value, ok := m.pairs[key] + return value, ok +} + +// GetDefault retrieves the string value of the given key. If no value was +// stored, the given default value is returned. +func (m *Meta) GetDefault(key string, def Value) Value { + if value, found := m.Get(key); found { + return value + } + return def +} + +// GetTitle returns the title of the metadata. It is the only key that has a +// defined default value: the string representation of the zettel identifier. +func (m *Meta) GetTitle() string { + if title, found := m.Get(KeyTitle); found { + return string(title) + } + return m.Zid.String() +} + +// All returns an iterator over all key/value pairs, except the zettel identifier +// and computed values. +func (m *Meta) All() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.firstKeys()(yield) + m.restKeys(notComputedKey)(yield) + } +} + +// Computed returns an iterator over all key/value pairs, except the zettel identifier. +func (m *Meta) Computed() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.firstKeys()(yield) + m.restKeys(anyKey)(yield) + } +} + +// Rest returns an iterator over all key/value pairs, except the zettel identifier, +// the main keys, and computed values. +func (m *Meta) Rest() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.restKeys(notComputedKey)(yield) + } +} + +// ComputedRest returns an iterator over all key/value pairs, except the zettel identifier, +// and the main keys. +func (m *Meta) ComputedRest() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.restKeys(anyKey)(yield) + } +} + +func (m *Meta) firstKeys() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + for _, key := range firstKeys { + if val, ok := m.pairs[key]; ok { + if !yield(key, val) { + return + } + } + } + } +} + +func (m *Meta) restKeys(addKeyPred func(Key) bool) iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + keys := slices.Sorted(maps.Keys(m.pairs)) + for _, key := range keys { + if !slices.Contains(firstKeys, key) && addKeyPred(key) { + if !yield(key, m.pairs[key]) { + return + } + } + } + } +} + +func notComputedKey(key string) bool { return !IsComputed(key) } +func anyKey(string) bool { return true } + +// Delete removes a key from the data. +func (m *Meta) Delete(key string) { + if key != KeyID { + delete(m.pairs, key) + } +} + +// Equal compares to metas for equality. +func (m *Meta) Equal(o *Meta, allowComputed bool) bool { + if m == nil && o == nil { + return true + } + if m == nil || o == nil || m.Zid != o.Zid { + return false + } + tested := set.New[string]() + for k, v := range m.pairs { + tested.Add(k) + if !equalValue(k, v, o, allowComputed) { + return false + } + } + for k, v := range o.pairs { + if !tested.Contains(k) && !equalValue(k, v, m, allowComputed) { + return false + } + } + return true +} + +func equalValue(key string, val Value, other *Meta, allowComputed bool) bool { + if allowComputed || !IsComputed(key) { + if valO, found := other.pairs[key]; !found || val != valO { + return false + } + } + return true +} + +// Sanitize all metadata keys and values, so that they can be written safely into a file. +func (m *Meta) Sanitize() { + if m == nil { + return + } + for key, val := range m.pairs { + newKey := RemoveNonGraphic(key) + if key == newKey { + m.pairs[key] = Value(RemoveNonGraphic(string(val))) + } else { + delete(m.pairs, key) + m.pairs[newKey] = Value(RemoveNonGraphic(string(val))) + } + } +} + +// RemoveNonGraphic changes the given string not to include non-graphical characters. +// It is needed to sanitize meta data. +func RemoveNonGraphic(s string) string { + if s == "" { + return "" + } + pos := 0 + var sb strings.Builder + for pos < len(s) { + nextPos := strings.IndexFunc(s[pos:], func(r rune) bool { return !unicode.IsGraphic(r) }) + if nextPos < 0 { + break + } + sb.WriteString(s[pos:nextPos]) + sb.WriteByte(' ') + _, size := utf8.DecodeRuneInString(s[nextPos:]) + pos = nextPos + size + } + if pos == 0 { + return strings.TrimSpace(s) + } + sb.WriteString(s[pos:]) + return strings.TrimSpace(sb.String()) +} ADDED domain/meta/meta_test.go Index: domain/meta/meta_test.go ================================================================== --- /dev/null +++ domain/meta/meta_test.go @@ -0,0 +1,266 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "iter" + "slices" + "strings" + "testing" + + "t73f.de/r/zsc/domain/id" +) + +const testID = id.Zid(98765432101234) + +func TestKeyIsValid(t *testing.T) { + t.Parallel() + validKeys := []string{"0", "a", "0-", "title", "title-----", strings.Repeat("r", 255)} + for _, key := range validKeys { + if !KeyIsValid(key) { + t.Errorf("Key %q wrongly identified as invalid key", key) + } + } + invalidKeys := []string{"", "-", "-a", "Title", "a_b", strings.Repeat("e", 256)} + for _, key := range invalidKeys { + if KeyIsValid(key) { + t.Errorf("Key %q wrongly identified as valid key", key) + } + } +} + +func TestTitleHeader(t *testing.T) { + t.Parallel() + m := New(testID) + if got, ok := m.Get(KeyTitle); ok && got != "" { + t.Errorf("Title is not empty, but %q", got) + } + addToMeta(m, KeyTitle, " ") + if got, ok := m.Get(KeyTitle); ok && got != "" { + t.Errorf("Title is not empty, but %q", got) + } + const st = "A simple text" + addToMeta(m, KeyTitle, " "+st+" ") + if got, ok := m.Get(KeyTitle); !ok || got != st { + t.Errorf("Title is not %q, but %q", st, got) + } + addToMeta(m, KeyTitle, " "+st+"\t") + const exp = st + " " + st + if got, ok := m.Get(KeyTitle); !ok || got != exp { + t.Errorf("Title is not %q, but %q", exp, got) + } + + m = New(testID) + const at = "A Title" + addToMeta(m, KeyTitle, at) + addToMeta(m, KeyTitle, " ") + if got, ok := m.Get(KeyTitle); !ok || got != at { + t.Errorf("Title is not %q, but %q", at, got) + } +} + +func checkTags(t *testing.T, exp []string, m *Meta) { + t.Helper() + got := slices.Collect(m.GetFields(KeyTags)) + for i, tag := range exp { + if i < len(got) { + if tag != got[i] { + t.Errorf("Pos=%d, expected %q, got %q", i, exp[i], got[i]) + } + } else { + t.Errorf("Expected %q, but is missing", exp[i]) + } + } + if len(exp) < len(got) { + t.Errorf("Extra tags: %q", got[len(exp):]) + } +} + +func TestTagsHeader(t *testing.T) { + t.Parallel() + m := New(testID) + checkTags(t, []string{}, m) + + addToMeta(m, KeyTags, "") + checkTags(t, []string{}, m) + + addToMeta(m, KeyTags, " #t1 #t2 #t3 #t4 ") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4"}, m) + + addToMeta(m, KeyTags, "#t5") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4", "#t5"}, m) + + addToMeta(m, KeyTags, "t6") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4", "#t5"}, m) +} + +func TestSyntax(t *testing.T) { + t.Parallel() + m := New(testID) + if got, ok := m.Get(KeySyntax); ok || got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + addToMeta(m, KeySyntax, " ") + if got, _ := m.Get(KeySyntax); got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + addToMeta(m, KeySyntax, "MarkDown") + const exp = "markdown" + if got, ok := m.Get(KeySyntax); !ok || got != exp { + t.Errorf("Syntax is not %q, but %q", exp, got) + } + addToMeta(m, KeySyntax, " ") + if got, _ := m.Get(KeySyntax); got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } +} + +func checkHeader(t *testing.T, exp map[string]string, gotI iter.Seq2[Key, Value]) { + t.Helper() + got := make(map[string]string) + gotI(func(key Key, val Value) bool { + got[key] = string(val) + if _, ok := exp[key]; !ok { + t.Errorf("Key %q is not expected, but has value %q", key, val) + } + return true + }) + for k, v := range exp { + if gv, ok := got[k]; !ok || v != gv { + if ok { + t.Errorf("Key %q is not %q, but %q", k, v, got[k]) + } else { + t.Errorf("Key %q missing, should have value %q", k, v) + } + } + } +} + +func TestDefaultHeader(t *testing.T) { + t.Parallel() + m := New(testID) + addToMeta(m, "h1", "d1") + addToMeta(m, "H2", "D2") + addToMeta(m, "H1", "D1.1") + exp := map[string]string{"h1": "d1 D1.1", "h2": "D2"} + checkHeader(t, exp, m.All()) + addToMeta(m, "", "d0") + checkHeader(t, exp, m.All()) + addToMeta(m, "h3", "") + exp["h3"] = "" + checkHeader(t, exp, m.All()) + addToMeta(m, "h3", " ") + checkHeader(t, exp, m.All()) + addToMeta(m, "h4", " ") + exp["h4"] = "" + checkHeader(t, exp, m.All()) +} + +func TestDelete(t *testing.T) { + t.Parallel() + m := New(testID) + m.Set("key", "val") + if got, ok := m.Get("key"); !ok || got != "val" { + t.Errorf("Value != %q, got: %v/%q", "val", ok, got) + } + m.Set("key", "") + if got, ok := m.Get("key"); !ok || got != "" { + t.Errorf("Value != %q, got: %v/%q", "", ok, got) + } + m.Delete("key") + if got, ok := m.Get("key"); ok || got != "" { + t.Errorf("Value != %q, got: %v/%q", "", ok, got) + } +} + +func TestEqual(t *testing.T) { + t.Parallel() + testcases := []struct { + pairs1, pairs2 []string + allowComputed bool + exp bool + }{ + {nil, nil, true, true}, + {nil, nil, false, true}, + {[]string{"a", "a"}, nil, false, false}, + {[]string{"a", "a"}, nil, true, false}, + {[]string{KeyFolge, "0"}, nil, true, false}, + {[]string{KeyFolge, "0"}, nil, false, true}, + {[]string{KeyFolge, "0"}, []string{KeyFolge, "0"}, true, true}, + {[]string{KeyFolge, "0"}, []string{KeyFolge, "0"}, false, true}, + } + for i, tc := range testcases { + m1 := pairs2meta(tc.pairs1) + m2 := pairs2meta(tc.pairs2) + got := m1.Equal(m2, tc.allowComputed) + if tc.exp != got { + t.Errorf("%d: %v =?= %v: expected=%v, but got=%v", i, tc.pairs1, tc.pairs2, tc.exp, got) + } + got = m2.Equal(m1, tc.allowComputed) + if tc.exp != got { + t.Errorf("%d: %v =!= %v: expected=%v, but got=%v", i, tc.pairs1, tc.pairs2, tc.exp, got) + } + } + + // Pathologic cases + var m1, m2 *Meta + if !m1.Equal(m2, true) { + t.Error("Nil metas should be treated equal") + } + m1 = New(testID) + if m1.Equal(m2, true) { + t.Error("Empty meta should not be equal to nil") + } + if m2.Equal(m1, true) { + t.Error("Nil meta should should not be equal to empty") + } + m2 = New(testID + 1) + if m1.Equal(m2, true) { + t.Error("Different ID should differentiate") + } + if m2.Equal(m1, true) { + t.Error("Different ID should differentiate") + } +} + +func pairs2meta(pairs []string) *Meta { + m := New(testID) + for i := 0; i < len(pairs); i += 2 { + m.Set(pairs[i], Value(pairs[i+1])) + } + return m +} + +func TestRemoveNonGraphic(t *testing.T) { + testCases := []struct { + inp string + exp string + }{ + {"", ""}, + {" ", ""}, + {"a", "a"}, + {"a ", "a"}, + {"a b", "a b"}, + {"\n", ""}, + {"a\n", "a"}, + {"a\nb", "a b"}, + {"a\tb", "a b"}, + } + for i, tc := range testCases { + got := RemoveNonGraphic(tc.inp) + if tc.exp != got { + t.Errorf("%q/%d: expected %q, but got %q", tc.inp, i, tc.exp, got) + } + } +} ADDED domain/meta/parse.go Index: domain/meta/parse.go ================================================================== --- /dev/null +++ domain/meta/parse.go @@ -0,0 +1,169 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "iter" + "slices" + "strings" + + "t73f.de/r/zero/set" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/input" +) + +// NewFromInput parses the meta data of a zettel. +func NewFromInput(zid id.Zid, inp *input.Input) *Meta { + if inp.Ch == '-' && inp.PeekN(0) == '-' && inp.PeekN(1) == '-' { + skipToEOL(inp) + inp.EatEOL() + } + meta := New(zid) + for { + inp.SkipSpace() + switch inp.Ch { + case '\r': + if inp.Peek() == '\n' { + inp.Next() + } + fallthrough + case '\n': + inp.Next() + return meta + case input.EOS: + return meta + case '%': + skipToEOL(inp) + inp.EatEOL() + continue + } + parseHeader(meta, inp) + if inp.Ch == '-' && inp.PeekN(0) == '-' && inp.PeekN(1) == '-' { + skipToEOL(inp) + inp.EatEOL() + meta.YamlSep = true + return meta + } + } +} + +func parseHeader(m *Meta, inp *input.Input) { + pos := inp.Pos + for isHeader(inp.Ch) { + inp.Next() + } + key := inp.Src[pos:inp.Pos] + inp.SkipSpace() + if inp.Ch == ':' { + inp.Next() + } + var val []byte + for { + inp.SkipSpace() + pos = inp.Pos + skipToEOL(inp) + val = append(val, inp.Src[pos:inp.Pos]...) + inp.EatEOL() + if !inp.IsSpace() { + break + } + val = append(val, ' ') + } + addToMeta(m, string(key), Value(val)) +} + +func skipToEOL(inp *input.Input) { + for { + switch inp.Ch { + case '\n', '\r', input.EOS: + return + } + inp.Next() + } +} + +// Return true iff rune is valid for header key. +func isHeader(ch rune) bool { + return ('a' <= ch && ch <= 'z') || + ('0' <= ch && ch <= '9') || + ch == '-' || + ('A' <= ch && ch <= 'Z') +} + +type predValidElem func(string) bool + +func addToSet(set *set.Set[string], it iter.Seq[string], useElem predValidElem) { + for e := range it { + if len(e) > 0 && useElem(e) { + set.Add(e) + } + } +} + +func addSet(m *Meta, key string, val Value, useElem predValidElem) { + newElems := val.Fields() + oldElems := m.GetFields(key) + + s := set.New[string]() + addToSet(s, newElems, useElem) + if s.Length() == 0 { + // Nothing to add. Maybe because of rejected elements. + return + } + addToSet(s, oldElems, useElem) + m.SetList(key, slices.Sorted(s.Values())) +} + +func addData(m *Meta, k string, v Value) { + if o, ok := m.Get(k); !ok || o == "" { + m.Set(k, v) + } else if v != "" { + m.Set(k, o+" "+v) + } +} + +func addToMeta(m *Meta, key string, val Value) { + v := val.TrimSpace() + key = strings.ToLower(key) + if !KeyIsValid(key) { + return + } + switch key { + case "", KeyID: + // Empty key and 'id' key will be ignored + return + } + + switch Type(key) { + case TypeTagSet: + addSet(m, key, v.ToLower(), func(s string) bool { return s[0] == '#' && len(s) > 1 }) + case TypeWord: + m.Set(key, v.ToLower()) + case TypeID: + if _, err := id.Parse(string(v)); err == nil { + m.Set(key, v) + } + case TypeIDSet: + addSet(m, key, v, func(s string) bool { + _, err := id.Parse(s) + return err == nil + }) + case TypeTimestamp: + if _, ok := v.AsTime(); ok { + m.Set(key, v) + } + default: + addData(m, key, v) + } +} ADDED domain/meta/parse_test.go Index: domain/meta/parse_test.go ================================================================== --- /dev/null +++ domain/meta/parse_test.go @@ -0,0 +1,188 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta_test + +import ( + "iter" + "slices" + "strings" + "testing" + + "t73f.de/r/zsc/domain/meta" + "t73f.de/r/zsc/input" +) + +func parseMetaStr(src string) *meta.Meta { + return meta.NewFromInput(testID, input.NewInput([]byte(src))) +} + +func TestEmpty(t *testing.T) { + t.Parallel() + m := parseMetaStr("") + if got, ok := m.Get(meta.KeySyntax); ok || got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + if got := slices.Collect(m.GetDefault(meta.KeyTags, "").Fields()); len(got) > 0 { + t.Errorf("Tags are not nil, but %v", got) + } +} + +func TestTitle(t *testing.T) { + t.Parallel() + td := []struct { + s string + e meta.Value + }{ + {meta.KeyTitle + ": a title", "a title"}, + {meta.KeyTitle + ": a\n\t title", "a title"}, + {meta.KeyTitle + ": a\n\t title\r\n x", "a title x"}, + {meta.KeyTitle + " AbC", "AbC"}, + {meta.KeyTitle + " AbC\n ded", "AbC ded"}, + {meta.KeyTitle + ": o\ntitle: p", "o p"}, + {meta.KeyTitle + ": O\n\ntitle: P", "O"}, + {meta.KeyTitle + ": b\r\ntitle: c", "b c"}, + {meta.KeyTitle + ": B\r\n\r\ntitle: C", "B"}, + {meta.KeyTitle + ": r\rtitle: q", "r q"}, + {meta.KeyTitle + ": R\r\rtitle: Q", "R"}, + } + for i, tc := range td { + m := parseMetaStr(tc.s) + if got, ok := m.Get(meta.KeyTitle); !ok || got != tc.e { + t.Log(m) + t.Errorf("TC=%d: expected %q, got %q", i, tc.e, got) + } + } +} + +func TestTags(t *testing.T) { + t.Parallel() + testcases := []struct { + src string + exp string + }{ + {"", ""}, + {meta.KeyTags + ":", ""}, + {meta.KeyTags + ": c", ""}, + {meta.KeyTags + ": #", ""}, + {meta.KeyTags + ": #c", "c"}, + {meta.KeyTags + ": #c #", "c"}, + {meta.KeyTags + ": #c #b", "b c"}, + {meta.KeyTags + ": #c # #", "c"}, + {meta.KeyTags + ": #c # #b", "b c"}, + } + for i, tc := range testcases { + m := parseMetaStr(tc.src) + tagsString, found := m.Get(meta.KeyTags) + if !found { + if tc.exp != "" { + t.Errorf("%d / %q: no %s found", i, tc.src, meta.KeyTags) + } + continue + } + tags := tagsString.AsTags() + if tc.exp == "" && len(tags) > 0 { + t.Errorf("%d / %q: expected no %s, but got %v", i, tc.src, meta.KeyTags, tags) + continue + } + got := strings.Join(tags, " ") + if tc.exp != got { + t.Errorf("%d / %q: expected %q, got: %q", i, tc.src, tc.exp, got) + } + } +} + +func TestNewFromInput(t *testing.T) { + t.Parallel() + testcases := []struct { + input string + exp []pair + }{ + {"", []pair{}}, + {" a:b", []pair{{"a", "b"}}}, + {"%a:b", []pair{}}, + {"a:b\r\n\r\nc:d", []pair{{"a", "b"}}}, + {"a:b\r\n%c:d", []pair{{"a", "b"}}}, + {"% a:b\r\n c:d", []pair{{"c", "d"}}}, + {"---\r\na:b\r\n", []pair{{"a", "b"}}}, + {"---\r\na:b\r\n--\r\nc:d", []pair{{"a", "b"}, {"c", "d"}}}, + {"---\r\na:b\r\n---\r\nc:d", []pair{{"a", "b"}}}, + {"---\r\na:b\r\n----\r\nc:d", []pair{{"a", "b"}}}, + {"new-title:\nnew-url:", []pair{{"new-title", ""}, {"new-url", ""}}}, + } + for i, tc := range testcases { + meta := parseMetaStr(tc.input) + if got := iter2pairs(meta.All()); !equalPairs(tc.exp, got) { + t.Errorf("TC=%d: expected=%v, got=%v", i, tc.exp, got) + } + } + + // Test, whether input position is correct. + inp := input.NewInput([]byte("---\na:b\n---\nX")) + m := meta.NewFromInput(testID, inp) + exp := []pair{{"a", "b"}} + if got := iter2pairs(m.All()); !equalPairs(exp, got) { + t.Errorf("Expected=%v, got=%v", exp, got) + } + expCh := 'X' + if gotCh := inp.Ch; gotCh != expCh { + t.Errorf("Expected=%v, got=%v", expCh, gotCh) + } +} + +type pair struct { + key meta.Key + val meta.Value +} + +func iter2pairs(it iter.Seq2[meta.Key, meta.Value]) (result []pair) { + it(func(key meta.Key, val meta.Value) bool { + result = append(result, pair{key, val}) + return true + }) + return result +} + +func equalPairs(one, two []pair) bool { + if len(one) != len(two) { + return false + } + for i := range len(one) { + if one[i].key != two[i].key || one[i].val != two[i].val { + return false + } + } + return true +} + +func TestPrecursorIDSet(t *testing.T) { + t.Parallel() + var testdata = []struct { + inp string + exp meta.Value + }{ + {"", ""}, + {"123", ""}, + {"12345678901234", "12345678901234"}, + {"123 12345678901234", "12345678901234"}, + {"12345678901234 123", "12345678901234"}, + {"01234567890123 123 12345678901234", "01234567890123 12345678901234"}, + {"12345678901234 01234567890123", "01234567890123 12345678901234"}, + } + for i, tc := range testdata { + m := parseMetaStr(meta.KeyPrecursor + ": " + tc.inp) + if got, ok := m.Get(meta.KeyPrecursor); (!ok && tc.exp != "") || tc.exp != got { + t.Errorf("TC=%d: expected %q, but got %q when parsing %q", i, tc.exp, got, tc.inp) + } + } +} ADDED domain/meta/type.go Index: domain/meta/type.go ================================================================== --- /dev/null +++ domain/meta/type.go @@ -0,0 +1,181 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "iter" + "strconv" + "strings" + "sync" + "time" + + zeroiter "t73f.de/r/zero/iter" + "t73f.de/r/zsc/domain/id" +) + +// DescriptionType is a description of a specific key type. +type DescriptionType struct { + Name string + IsSet bool +} + +// String returns the string representation of the given type +func (t DescriptionType) String() string { return t.Name } + +var registeredTypes = make(map[string]*DescriptionType) + +func registerType(name string, isSet bool) *DescriptionType { + if _, ok := registeredTypes[name]; ok { + panic("Type '" + name + "' already registered") + } + t := &DescriptionType{name, isSet} + registeredTypes[name] = t + return t +} + +// Values of the metadata key/value types. +// +// See [Supported Key Types]. +// +// [Supported Key Types]: https://zettelstore.de/manual/h/00001006030000 +const ( + MetaCredential = "Credential" + MetaEmpty = "EString" + MetaID = "Identifier" + MetaIDSet = "IdentifierSet" + MetaNumber = "Number" + MetaString = "String" + MetaTagSet = "TagSet" + MetaTimestamp = "Timestamp" + MetaURL = "URL" + MetaWord = "Word" +) + +// Supported key types. +var ( + TypeCredential = registerType(MetaCredential, false) + TypeEmpty = registerType(MetaEmpty, false) + TypeID = registerType(MetaID, false) + TypeIDSet = registerType(MetaIDSet, true) + TypeNumber = registerType(MetaNumber, false) + TypeString = registerType(MetaString, false) + TypeTagSet = registerType(MetaTagSet, true) + TypeTimestamp = registerType(MetaTimestamp, false) + TypeURL = registerType(MetaURL, false) + TypeWord = registerType(MetaWord, false) +) + +// Type returns a type hint for the given key. If no type hint is specified, +// TypeUnknown is returned. +func (*Meta) Type(key string) *DescriptionType { + return Type(key) +} + +// Some constants for key suffixes that determine a type. +const ( + SuffixKeyRole = "-role" + SuffixKeyURL = "-url" +) + +var ( + cachedTypedKeys = make(map[string]*DescriptionType) + mxTypedKey sync.RWMutex + suffixTypes = map[string]*DescriptionType{ + "-date": TypeTimestamp, + "-number": TypeNumber, + SuffixKeyRole: TypeWord, + "-time": TypeTimestamp, + SuffixKeyURL: TypeURL, + "-zettel": TypeID, + "-zid": TypeID, + "-zids": TypeIDSet, + } +) + +// Type returns a type hint for the given key. If no type hint is specified, +// TypeEmpty is returned. +func Type(key string) *DescriptionType { + if k, ok := registeredKeys[key]; ok { + return k.Type + } + mxTypedKey.RLock() + k, found := cachedTypedKeys[key] + mxTypedKey.RUnlock() + if found { + return k + } + + for suffix, t := range suffixTypes { + if strings.HasSuffix(key, suffix) { + mxTypedKey.Lock() + defer mxTypedKey.Unlock() + // Double check to avoid races + if _, found = cachedTypedKeys[key]; !found { + cachedTypedKeys[key] = t + } + return t + } + } + return TypeEmpty +} + +// SetList stores the given string list value under the given key. +func (m *Meta) SetList(key string, values []string) { + if key != KeyID { + for i, val := range values { + values[i] = string(Value(val).TrimSpace()) + } + m.pairs[key] = Value(strings.Join(values, " ")) + } +} + +// SetWord stores the given word under the given key. +func (m *Meta) SetWord(key, word string) { + for val := range Value(word).Elems() { + m.Set(key, val) + return + } +} + +// SetNow stores the current timestamp under the given key. +func (m *Meta) SetNow(key string) { + m.Set(key, Value(time.Now().Local().Format(id.TimestampLayout))) +} + +// GetBool returns the boolean value of the given key. +func (m *Meta) GetBool(key string) bool { + if val, ok := m.Get(key); ok { + return val.AsBool() + } + return false +} + +// GetFields returns the metadata value as a sequence of string. The bool value +// signals, whether there was a value stored or not. +func (m *Meta) GetFields(key Key) iter.Seq[string] { + if val, ok := m.Get(key); ok { + return val.Fields() + } + return zeroiter.EmptySeq[string]() +} + +// GetNumber retrieves the numeric value of a given key. +func (m *Meta) GetNumber(key string, def int64) int64 { + if value, ok := m.Get(key); ok { + if num, err := strconv.ParseInt(string(value), 10, 64); err == nil { + return num + } + } + return def +} ADDED domain/meta/type_test.go Index: domain/meta/type_test.go ================================================================== --- /dev/null +++ domain/meta/type_test.go @@ -0,0 +1,79 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta_test + +import ( + "strconv" + "testing" + "time" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +func TestNow(t *testing.T) { + t.Parallel() + m := meta.New(id.Invalid) + m.SetNow("key") + val, ok := m.Get("key") + if !ok { + t.Error("Unable to get value of key") + } + if len(val) != 14 { + t.Errorf("Value is not 14 digits long: %q", val) + } + if _, err := strconv.ParseInt(string(val), 10, 64); err != nil { + t.Errorf("Unable to parse %q as an int64: %v", val, err) + } + if _, ok = val.AsTime(); !ok { + t.Errorf("Unable to get time from value %q", val) + } +} + +func TestTimeValue(t *testing.T) { + t.Parallel() + testCases := []struct { + value meta.Value + valid bool + exp time.Time + }{ + {"", false, time.Time{}}, + {"1", false, time.Time{}}, + {"00000000000000", false, time.Time{}}, + {"98765432109876", false, time.Time{}}, + {"20201221111905", true, time.Date(2020, time.December, 21, 11, 19, 5, 0, time.UTC)}, + {"2023", true, time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {"20231", false, time.Time{}}, + {"202310", true, time.Date(2023, time.October, 1, 0, 0, 0, 0, time.UTC)}, + {"2023103", false, time.Time{}}, + {"20231030", true, time.Date(2023, time.October, 30, 0, 0, 0, 0, time.UTC)}, + {"202310301", false, time.Time{}}, + {"2023103016", true, time.Date(2023, time.October, 30, 16, 0, 0, 0, time.UTC)}, + {"20231030165", false, time.Time{}}, + {"202310301654", true, time.Date(2023, time.October, 30, 16, 54, 0, 0, time.UTC)}, + {"2023103016541", false, time.Time{}}, + {"20231030165417", true, time.Date(2023, time.October, 30, 16, 54, 17, 0, time.UTC)}, + {"2023103916541700", false, time.Time{}}, + } + for i, tc := range testCases { + got, ok := tc.value.AsTime() + if ok != tc.valid { + t.Errorf("%d: parsing of %q should be %v, but got %v", i, tc.value, tc.valid, ok) + continue + } + if got != tc.exp { + t.Errorf("%d: parsing of %q should return %v, but got %v", i, tc.value, tc.exp, got) + } + } +} ADDED domain/meta/values.go Index: domain/meta/values.go ================================================================== --- /dev/null +++ domain/meta/values.go @@ -0,0 +1,225 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "fmt" + "iter" + "slices" + "strings" + "time" + + zeroiter "t73f.de/r/zero/iter" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/input" +) + +// Value ist a single metadata value. +type Value string + +// AsBool returns the value interpreted as a bool. +func (val Value) AsBool() bool { + if len(val) > 0 { + switch val[0] { + case '0', 'f', 'F', 'n', 'N': + return false + } + } + return true +} + +// AsTime returns the time value of the given value. +func (val Value) AsTime() (time.Time, bool) { + if t, err := time.Parse(id.TimestampLayout, ExpandTimestamp(val)); err == nil { + return t, true + } + return time.Time{}, false +} + +// ExpandTimestamp makes a short-form timestamp larger. +func ExpandTimestamp(val Value) string { + switch l := len(val); l { + case 4: // YYYY + return string(val) + "0101000000" + case 6: // YYYYMM + return string(val) + "01000000" + case 8, 10, 12: // YYYYMMDD, YYYYMMDDhh, YYYYMMDDhhmm + return string(val) + "000000"[:14-l] + case 14: // YYYYMMDDhhmmss + return string(val) + default: + if l > 14 { + return string(val[:14]) + } + return string(val) + } +} + +// Fields iterates over the value as a list/set of strings. +func (val Value) Fields() iter.Seq[string] { + return strings.FieldsSeq(string(val)) +} + +// Elems iterates over the value as a list/set of values. +func (val Value) Elems() iter.Seq[Value] { + return zeroiter.MapSeq(val.Fields(), func(s string) Value { return Value(s) }) +} + +// AsSlice transforms a value into a slice of strings. +func (val Value) AsSlice() []string { + return strings.Fields(string(val)) +} + +// ToLower maps the value to lowercase runes. +func (val Value) ToLower() Value { return Value(strings.ToLower(string(val))) } + +// TrimSpace removes all leading and remaining space from value +func (val Value) TrimSpace() Value { + return Value(strings.TrimFunc(string(val), input.IsSpace)) +} + +// AsTags returns the value as a sequence of normalized tags. +func (val Value) AsTags() []string { + return slices.Collect(zeroiter.MapSeq( + val.Fields(), + func(e string) string { return string(Value(e).ToLower().CleanTag()) })) +} + +// CleanTag removes the number character ('#') from a tag value. +func (val Value) CleanTag() Value { + if len(val) > 1 && val[0] == '#' { + return val[1:] + } + return val +} + +// NormalizeTag adds a missing prefix "#" to the tag +func (val Value) NormalizeTag() Value { + if len(val) > 0 && val[0] == '#' { + return val + } + return "#" + val +} + +// Predefined metadata values. +const ( + ValueFalse = "false" + ValueTrue = "true" + ValueLangEN = "en" // Default for "lang" + ValueRoleConfiguration = "configuration" // A role for internal zettel + ValueRoleTag = "tag" // A role for tag zettel + ValueRoleRole = "role" // A role for role zettel + ValueRoleZettel = "zettel" // A role for zettel + ValueSyntaxCSS = "css" // Syntax: CSS + ValueSyntaxDraw = "draw" // Syntax: Drawing + ValueSyntaxGif = "gif" // Syntax: GIF image + ValueSyntaxHTML = "html" // Syntax: HTML + ValueSyntaxJPEG = "jpeg" // Syntax: JPEG image + ValueSyntaxJPG = "jpg" // Syntax: PEG image + ValueSyntaxMarkdown = "markdown" // Syntax: Markdown / CommonMark + ValueSyntaxMD = "md" // Syntax: Markdown / CommonMark + ValueSyntaxNone = "none" // Syntax: no syntax / content, just metadata + ValueSyntaxPlain = "plain" // Syntax: plain text + ValueSyntaxPNG = "png" // Syntax: PNG image + ValueSyntaxSVG = "svg" // Syntax: SVG + ValueSyntaxSxn = "sxn" // Syntax: S-Expression + ValueSyntaxText = "text" // Syntax: plain text + ValueSyntaxTxt = "txt" // Syntax: plain text + ValueSyntaxWebp = "webp" // Syntax: WEBP image + ValueSyntaxZmk = "zmk" // Syntax: Zettelmarkup + ValueUserRoleCreator = "creator" + ValueUserRoleOwner = "owner" + ValueUserRoleReader = "reader" + ValueUserRoleWriter = "writer" + ValueVisibilityCreator = "creator" + ValueVisibilityExpert = "expert" + ValueVisibilityLogin = "login" + ValueVisibilityOwner = "owner" + ValueVisibilityPublic = "public" +) + +// DefaultSyntax is the default value for metadata 'syntax'. +const DefaultSyntax = ValueSyntaxPlain + +// Visibility enumerates the variations of the 'visibility' meta key. +type Visibility int + +// Supported values for visibility. +const ( + _ Visibility = iota + VisibilityUnknown + VisibilityPublic + VisibilityCreator + VisibilityLogin + VisibilityOwner + VisibilityExpert +) + +var visMap = map[Value]Visibility{ + ValueVisibilityPublic: VisibilityPublic, + ValueVisibilityCreator: VisibilityCreator, + ValueVisibilityLogin: VisibilityLogin, + ValueVisibilityOwner: VisibilityOwner, + ValueVisibilityExpert: VisibilityExpert, +} +var revVisMap = map[Visibility]Value{} + +func init() { + for k, v := range visMap { + revVisMap[v] = k + } +} + +// AsVisibility returns the visibility value of the given value string +func (val Value) AsVisibility() Visibility { + if vis, ok := visMap[val]; ok { + return vis + } + return VisibilityUnknown +} + +func (v Visibility) String() string { + if s, ok := revVisMap[v]; ok { + return string(s) + } + return fmt.Sprintf("Unknown (%d)", v) +} + +// UserRole enumerates the supported values of meta key 'user-role'. +type UserRole int + +// Supported values for user roles. +const ( + _ UserRole = iota + UserRoleUnknown + UserRoleCreator + UserRoleReader + UserRoleWriter + UserRoleOwner +) + +var urMap = map[Value]UserRole{ + ValueUserRoleCreator: UserRoleCreator, + ValueUserRoleReader: UserRoleReader, + ValueUserRoleWriter: UserRoleWriter, + ValueUserRoleOwner: UserRoleOwner, +} + +// AsUserRole role returns the user role of the given string. +func (val Value) AsUserRole() UserRole { + if ur, ok := urMap[val]; ok { + return ur + } + return UserRoleUnknown +} ADDED domain/meta/write.go Index: domain/meta/write.go ================================================================== --- /dev/null +++ domain/meta/write.go @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import "io" + +// Write writes metadata to a writer, excluding computed and propery values. +func (m *Meta) Write(w io.Writer) (int, error) { + return m.doWrite(w, IsComputed) +} + +// WriteComputed writes metadata to a writer, including computed values, +// but excluding property values. +func (m *Meta) WriteComputed(w io.Writer) (int, error) { + return m.doWrite(w, IsProperty) +} + +func (m *Meta) doWrite(w io.Writer, ignoreKeyPred func(string) bool) (length int, err error) { + for key, val := range m.Computed() { + if ignoreKeyPred(key) { + continue + } + if err != nil { + break + } + var l int + l, err = io.WriteString(w, key) + length += l + if err == nil { + l, err = w.Write(colonSpace) + length += l + } + if err == nil { + l, err = io.WriteString(w, string(val)) + length += l + } + if err == nil { + l, err = w.Write(newline) + length += l + } + } + return length, err +} + +var ( + colonSpace = []byte{':', ' '} + newline = []byte{'\n'} +) ADDED domain/meta/write_test.go Index: domain/meta/write_test.go ================================================================== --- /dev/null +++ domain/meta/write_test.go @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2020-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta_test + +import ( + "strings" + "testing" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +const testID = id.Zid(98765432101234) + +func newMeta(title string, tags []string, syntax string) *meta.Meta { + m := meta.New(testID) + if title != "" { + m.Set(meta.KeyTitle, meta.Value(title)) + } + if tags != nil { + m.Set(meta.KeyTags, meta.Value(strings.Join(tags, " "))) + } + if syntax != "" { + m.Set(meta.KeySyntax, meta.Value(syntax)) + } + return m +} +func assertWriteMeta(t *testing.T, m *meta.Meta, expected string) { + t.Helper() + var sb strings.Builder + _, _ = m.Write(&sb) + if got := sb.String(); got != expected { + t.Errorf("\nExp: %q\ngot: %q", expected, got) + } +} + +func TestWriteMeta(t *testing.T) { + t.Parallel() + assertWriteMeta(t, newMeta("", nil, ""), "") + + m := newMeta("TITLE", []string{"#t1", "#t2"}, "syntax") + assertWriteMeta(t, m, "title: TITLE\ntags: #t1 #t2\nsyntax: syntax\n") + + m = newMeta("TITLE", nil, "") + m.Set("user", "zettel") + m.Set("auth", "basic") + assertWriteMeta(t, m, "title: TITLE\nauth: basic\nuser: zettel\n") +} Index: go.mod ================================================================== --- go.mod +++ go.mod @@ -1,9 +1,10 @@ module t73f.de/r/zsc -go 1.23 +go 1.24 require ( - t73f.de/r/sx v0.0.0-20240814083626-4df0ec6454b5 - t73f.de/r/sxwebs v0.0.0-20241031144449-53c3b2ed1a6f - t73f.de/r/webs v0.0.0-20241031141359-cd4f76a622cd + t73f.de/r/sx v0.0.0-20250226205800-c12af029b6d3 + t73f.de/r/sxwebs v0.0.0-20250226210617-7bc3145c269b + t73f.de/r/webs v0.0.0-20250226210341-4a531b8bfb18 + t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7 ) Index: go.sum ================================================================== --- go.sum +++ go.sum @@ -1,6 +1,8 @@ -t73f.de/r/sx v0.0.0-20240814083626-4df0ec6454b5 h1:ug4hohM6pK28M8Uo0o3+XvjBure2wfEtuCnHVIdqBZY= -t73f.de/r/sx v0.0.0-20240814083626-4df0ec6454b5/go.mod h1:VRvsWoBErPKvMieDMMk1hsh1tb9sA4ijEQWGw/TbtQ0= -t73f.de/r/sxwebs v0.0.0-20241031144449-53c3b2ed1a6f h1:VJ4S7YWy9tCJuFz5MckqUjjktPaf0kpnTkNBVRVXpo4= -t73f.de/r/sxwebs v0.0.0-20241031144449-53c3b2ed1a6f/go.mod h1:IaM+U+LvYTYeuiIS5cwZW6kcEpdwoKBYVCU7LZr4Sgk= -t73f.de/r/webs v0.0.0-20241031141359-cd4f76a622cd h1:+7cqJonXKDso+uPvsvOPl7BiLkhj8VQT/Has8qC5VIQ= -t73f.de/r/webs v0.0.0-20241031141359-cd4f76a622cd/go.mod h1:NSoOON8be62MfQZzlCApK27Jt2zhIa6Vrmo9RJ4tOnQ= +t73f.de/r/sx v0.0.0-20250226205800-c12af029b6d3 h1:Jek4x1Qp59SWXI1enWVTeP1wxcVO96FuBpJBnnwOY98= +t73f.de/r/sx v0.0.0-20250226205800-c12af029b6d3/go.mod h1:hzg05uSCMk3D/DWaL0pdlowfL2aWQeGIfD1S04vV+Xg= +t73f.de/r/sxwebs v0.0.0-20250226210617-7bc3145c269b h1:X+9mMDd3fKML5SPcQk4n28oDGFUwqjDiSmQrH2LHZwI= +t73f.de/r/sxwebs v0.0.0-20250226210617-7bc3145c269b/go.mod h1:p+3JCSzNm9e+Yyub0ODRiLDeKaGVYWvBKYANZaAWYIA= +t73f.de/r/webs v0.0.0-20250226210341-4a531b8bfb18 h1:p7rOFBzP6FE/aYN5MUfmGDrKP1H1IFs6v19T7hm7rXI= +t73f.de/r/webs v0.0.0-20250226210341-4a531b8bfb18/go.mod h1:zk92hSKB4iWyT290+163seNzu350TA9XLATC9kOldqo= +t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7 h1:OuzHSfniY8UzLmo5zp1w23Kd9h7x9CSXP2jQ+kppeqU= +t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7/go.mod h1:T1vFcHoymUQcr7+vENBkS1yryZRZ3YB8uRtnMy8yRBA= DELETED maps/maps.go Index: maps/maps.go ================================================================== --- maps/maps.go +++ /dev/null @@ -1,30 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022-present Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client is licensed under the latest version of the EUPL -// (European Union Public License). Please see file LICENSE.txt for your rights -// and obligations under this license. -// -// SPDX-License-Identifier: EUPL-1.2 -// SPDX-FileCopyrightText: 2022-present Detlef Stern -//----------------------------------------------------------------------------- - -// Package maps provides utility functions to work with maps. -package maps - -import "sort" - -// Keys returns the sorted list of string keys of the given map. -func Keys[T any](m map[string]T) []string { - if len(m) == 0 { - return nil - } - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - sort.Strings(result) - return result -} DELETED maps/maps_test.go Index: maps/maps_test.go ================================================================== --- maps/maps_test.go +++ /dev/null @@ -1,49 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022-present Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client is licensed under the latest version of the EUPL -// (European Union Public License). Please see file LICENSE.txt for your rights -// and obligations under this license. -// -// SPDX-License-Identifier: EUPL-1.2 -// SPDX-FileCopyrightText: 2022-present Detlef Stern -//----------------------------------------------------------------------------- - -package maps_test - -import ( - "testing" - - "t73f.de/r/zsc/maps" -) - -func isSorted(seq []string) bool { - for i := 1; i < len(seq); i++ { - if seq[i] < seq[i-1] { - return false - } - } - return true -} - -func TestKeys(t *testing.T) { - testcases := []struct{ keys []string }{ - {nil}, {[]string{""}}, - {[]string{"z", "y", "a"}}, - } - for i, tc := range testcases { - m := make(map[string]struct{}) - for _, k := range tc.keys { - m[k] = struct{}{} - } - got := maps.Keys(m) - if len(got) != len(tc.keys) { - t.Errorf("%d: wrong number of keys: exp %d, got %d", i, len(tc.keys), len(got)) - } - if !isSorted(got) { - t.Errorf("%d: keys not sorted: %v", i, got) - } - } -} Index: sexp/sexp.go ================================================================== --- sexp/sexp.go +++ sexp/sexp.go @@ -107,12 +107,12 @@ func ParseMeta(pair *sx.Pair) (api.ZettelMeta, error) { if err := CheckSymbol(pair.Car(), "meta"); err != nil { return nil, err } res := api.ZettelMeta{} - for node := pair.Tail(); node != nil; node = node.Tail() { - mVals, err := ParseList(node.Car(), "ys") + for obj := range pair.Tail().Values() { + mVals, err := ParseList(obj, "ys") if err != nil { return nil, err } res[(mVals[0].(*sx.Symbol)).GetValue()] = mVals[1].(sx.String).GetValue() } @@ -134,26 +134,35 @@ } return api.ZettelRights(i64), nil } // ParseList parses the given object as a proper list, based on a type specification. +// +// 'b' expects a boolean, 'i' an int64, 'o' any object, 'p' a pair, 's' a string, +// and 'y' expects a symbol. A 'r' as the last type spracification matches all +// remaining values, including a non existent object. func ParseList(obj sx.Object, spec string) (sx.Vector, error) { pair, isPair := sx.GetPair(obj) if !isPair { return nil, fmt.Errorf("not a list: %T/%v", obj, obj) } if pair == nil { + if spec == "r" { + return sx.Vector{sx.Nil()}, nil + } if spec == "" { return nil, nil } return nil, ErrElementsMissing } - result := make(sx.Vector, 0, len(spec)) + specLen := len(spec) + result := make(sx.Vector, 0, specLen) node, i := pair, 0 +loop: for ; node != nil; i++ { - if i >= len(spec) { + if i >= specLen { return nil, ErrNoSpec } var val sx.Object var ok bool car := node.Car() @@ -164,10 +173,17 @@ val, ok = car.(sx.Int64) case 'o': val, ok = car, true case 'p': val, ok = sx.GetPair(car) + case 'r': + if i < specLen-1 { + return nil, fmt.Errorf("spec 'r' must be the last: %v", spec) + } + result = append(result, node) + i++ + break loop case 's': val, ok = sx.GetString(car) case 'y': val, ok = sx.GetSymbol(car) default: @@ -181,12 +197,15 @@ if !isNextPair { return nil, sx.ErrImproper{Pair: pair} } node = next } - if i < len(spec) { - return nil, ErrElementsMissing + if i < specLen { + if lastSpec := specLen - 1; i < lastSpec || spec[lastSpec] != 'r' { + return nil, ErrElementsMissing + } + result = append(result, sx.Nil()) } return result, nil } // ErrElementsMissing is returned, Index: sexp/sexp_test.go ================================================================== --- sexp/sexp_test.go +++ sexp/sexp_test.go @@ -52,6 +52,38 @@ t.Error("length == 1, but got: ", elems) } else { _ = elems[0].(sx.String) } + if elems, err := sexp.ParseList(sx.Nil(), "r"); err != nil { + t.Error(err) + } else if len(elems) != 1 { + t.Error("length == 1, but got: ", elems) + } else if !sx.IsNil(elems[0]) { + t.Error("must be nil, but got:", elems[0]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "r"); err != nil { + t.Error(err) + } else if len(elems) != 1 { + t.Error("length == 1, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeList(sx.MakeString("a"))) { + t.Error("must be (\"a\"), but got:", elems[0]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "sr"); err != nil { + t.Error(err) + } else if len(elems) != 2 { + t.Error("length == 2, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeString("a")) { + t.Error("0-th must be \"a\", but got:", elems[0]) + } else if !sx.IsNil(elems[1]) { + t.Error("must be nil, but got:", elems[1]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a"), sx.MakeString("b"), sx.MakeString("c")), "sr"); err != nil { + t.Error(err) + } else if len(elems) != 2 { + t.Error("length == 2, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeString("a")) { + t.Error("0-th must be \"a\", but got:", elems[0]) + } else if !elems[1].IsEqual(sx.MakeList(sx.MakeString("b"), sx.MakeString("c"))) { + t.Error("must be nil, but got:", elems[1]) + } } Index: shtml/const.go ================================================================== --- shtml/const.go +++ shtml/const.go @@ -17,11 +17,11 @@ // Symbols for HTML header tags var ( SymBody = sx.MakeSymbol("body") SymHead = sx.MakeSymbol("head") - SymHtml = sx.MakeSymbol("html") + SymHTML = sx.MakeSymbol("html") SymMeta = sx.MakeSymbol("meta") SymScript = sx.MakeSymbol("script") SymTitle = sx.MakeSymbol("title") ) @@ -69,11 +69,11 @@ // Symbols for HTML attribute keys var ( symAttrAlt = sx.MakeSymbol("alt") SymAttrClass = sx.MakeSymbol("class") SymAttrHref = sx.MakeSymbol("href") - SymAttrId = sx.MakeSymbol("id") + SymAttrID = sx.MakeSymbol("id") SymAttrLang = sx.MakeSymbol("lang") SymAttrOpen = sx.MakeSymbol("open") SymAttrRel = sx.MakeSymbol("rel") SymAttrRole = sx.MakeSymbol("role") SymAttrSrc = sx.MakeSymbol("src") Index: shtml/lang.go ================================================================== --- shtml/lang.go +++ shtml/lang.go @@ -14,11 +14,11 @@ package shtml import ( "strings" - "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/meta" ) // LangStack is a stack to store the nesting of "lang" attribute values. // It is used to generate typographically correct quotes. type LangStack []string @@ -83,14 +83,14 @@ // GetNBSp returns true, if there must be a non-breaking space between the // quote entities and the quoted text. func (qi *QuoteInfo) GetNBSp() bool { return qi.nbsp } var langQuotes = map[string]*QuoteInfo{ - "": {""", """, """, """, false}, - api.ValueLangEN: {"“", "”", "‘", "’", false}, - "de": {"„", "“", "‚", "‘", false}, - "fr": {"«", "»", "‹", "›", true}, + "": {""", """, """, """, false}, + meta.ValueLangEN: {"“", "”", "‘", "’", false}, + "de": {"„", "“", "‚", "‘", false}, + "fr": {"«", "»", "‹", "›", true}, } // GetQuoteInfo returns language specific data about quotes. func GetQuoteInfo(lang string) *QuoteInfo { langFields := strings.FieldsFunc(lang, func(r rune) bool { return r == '-' || r == '_' }) Index: shtml/shtml.go ================================================================== --- shtml/shtml.go +++ shtml/shtml.go @@ -22,12 +22,12 @@ "t73f.de/r/sx" "t73f.de/r/sxwebs/sxhtml" "t73f.de/r/zsc/api" "t73f.de/r/zsc/attrs" + "t73f.de/r/zsc/domain/meta" "t73f.de/r/zsc/sz" - "t73f.de/r/zsc/text" ) // Evaluator will transform a s-expression that encodes the zettel AST into an s-expression // that represents HTML. type Evaluator struct { @@ -121,17 +121,19 @@ if env.err != nil || len(env.endnotes) == 0 { return nil } var result sx.ListBuilder - result.Add(SymOL) - result.Add(sx.Nil().Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnotes"))).Cons(sxhtml.SymAttr)) + result.AddN( + SymOL, + sx.Nil().Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnotes"))).Cons(sxhtml.SymAttr), + ) for i, fni := range env.endnotes { noteNum := strconv.Itoa(i + 1) attrs := fni.attrs.Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnote"))). Cons(sx.Cons(SymAttrValue, sx.MakeString(noteNum))). - Cons(sx.Cons(SymAttrId, sx.MakeString("fn:"+fni.noteID))). + Cons(sx.Cons(SymAttrID, sx.MakeString("fn:"+fni.noteID))). Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-endnote"))). Cons(sxhtml.SymAttr) backref := sx.Nil().Cons(sx.MakeString("\u21a9\ufe0e")). Cons(sx.Nil(). @@ -140,15 +142,13 @@ Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-backlink"))). Cons(sxhtml.SymAttr)). Cons(SymA) var li sx.ListBuilder - li.Add(SymLI) - li.Add(attrs) + li.AddN(SymLI, attrs) li.ExtendBang(fni.noteHx) - li.Add(sx.MakeString(" ")) - li.Add(backref) + li.AddN(sx.MakeString(" "), backref) result.Add(li.List()) } return result.List() } @@ -252,13 +252,13 @@ ev.bind(sz.SymTypeURL, 2, evalMetaString) ev.bind(sz.SymTypeWord, 2, evalMetaString) evalMetaSet := func(args sx.Vector, env *Environment) sx.Object { var sb strings.Builder - for elem := getList(args[1], env); elem != nil; elem = elem.Tail() { + for obj := range getList(args[1], env).Values() { sb.WriteByte(' ') - sb.WriteString(getString(elem.Car(), env).GetValue()) + sb.WriteString(getString(obj, env).GetValue()) } s := sb.String() if len(s) > 0 { s = s[1:] } @@ -267,16 +267,10 @@ Set("content", s) return ev.EvaluateMeta(a) } ev.bind(sz.SymTypeIDSet, 2, evalMetaSet) ev.bind(sz.SymTypeTagSet, 2, evalMetaSet) - ev.bind(sz.SymTypeZettelmarkup, 2, func(args sx.Vector, env *Environment) sx.Object { - a := make(attrs.Attributes, 2). - Set("name", getSymbol(args[0], env).GetValue()). - Set("content", text.EvaluateInlineString(getList(args[1], env))) - return ev.EvaluateMeta(a) - }) } // EvaluateMeta returns HTML meta object for an attribute. func (ev *Evaluator) EvaluateMeta(a attrs.Attributes) *sx.Pair { return sx.Nil().Cons(EvaluateAttrbute(a)).Cons(SymMeta) @@ -338,12 +332,12 @@ } ddBlock := getList(ev.Eval(args[pos], env), env) if ddBlock == nil { continue } - for ddlst := ddBlock; ddlst != nil; ddlst = ddlst.Tail() { - dditem := getList(ddlst.Car(), env) + for ddlst := range ddBlock.Values() { + dditem := getList(ddlst, env) items.Add(dditem.Cons(symDD)) } } return items.List() }) @@ -411,11 +405,11 @@ }) ev.bind(sz.SymVerbatimHTML, 2, ev.evalHTML) ev.bind(sz.SymVerbatimMath, 2, func(args sx.Vector, env *Environment) sx.Object { return evalVerbatim(GetAttributes(args[0], env).AddClass("zs-math"), getString(args[1], env)) }) - ev.bind(sz.SymVerbatimProg, 2, func(args sx.Vector, env *Environment) sx.Object { + ev.bind(sz.SymVerbatimCode, 2, func(args sx.Vector, env *Environment) sx.Object { a := GetAttributes(args[0], env) content := getString(args[1], env) if a.HasDefault() { content = sx.MakeString(visibleReplacer.Replace(content.GetValue())) } @@ -435,10 +429,11 @@ return sx.Nil() } if refValue := getString(ref.Tail().Car(), env); refValue.GetValue() != "" { if refSym, isRefSym := sx.GetSymbol(refKind); isRefSym && refSym.IsEqualSymbol(sz.SymRefStateExternal) { a := GetAttributes(args[0], env).Set("src", refValue.GetValue()).AddClass("external") + // TODO: if len(args) > 2, add "alt" attr based on args[2:], as in SymEmbed return sx.Nil().Cons(sx.Nil().Cons(EvaluateAttrbute(a)).Cons(SymIMG)).Cons(SymP) } return sx.MakeList( sxhtml.SymInlineComment, sx.MakeString("transclude"), @@ -466,12 +461,12 @@ } } func (ev *Evaluator) evalDescriptionTerm(term *sx.Pair, env *Environment) *sx.Pair { var result sx.ListBuilder - for node := term; node != nil; node = node.Tail() { - elem := ev.Eval(node.Car(), env) + for obj := range term.Values() { + elem := ev.Eval(obj, env) result.Add(elem) } return result.List() } @@ -479,12 +474,12 @@ if pairs == nil { return nil } var row sx.ListBuilder row.Add(symTR) - for pair := pairs; pair != nil; pair = pair.Tail() { - row.Add(sx.Cons(sym, ev.Eval(pair.Car(), env))) + for obj := range pairs.Values() { + row.Add(sx.Cons(sym, ev.Eval(obj, env))) } return row.List() } func (ev *Evaluator) makeCellFn(align string) EvalFn { return func(args sx.Vector, env *Environment) sx.Object { @@ -602,11 +597,11 @@ } return sx.MakeList(SymIMG, EvaluateAttrbute(a)) }) ev.bind(sz.SymEmbedBLOB, 3, func(args sx.Vector, env *Environment) sx.Object { a, syntax, data := GetAttributes(args[0], env), getString(args[1], env), getString(args[2], env) - summary, hasSummary := a.Get(api.KeySummary) + summary, hasSummary := a.Get(meta.KeySummary) if !hasSummary { summary = "" } return evalBLOB( sx.MakeList(sxhtml.SymListSplice, sx.MakeString(summary)), @@ -662,11 +657,11 @@ hrefAttr := sx.Nil().Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-noteref"))). Cons(sx.Cons(SymAttrHref, sx.MakeString("#fn:"+noteID))). Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-noteref"))). Cons(sxhtml.SymAttr) href := sx.Nil().Cons(sx.MakeString(noteNum)).Cons(hrefAttr).Cons(SymA) - supAttr := sx.Nil().Cons(sx.Cons(SymAttrId, sx.MakeString("fnref:"+noteID))).Cons(sxhtml.SymAttr) + supAttr := sx.Nil().Cons(sx.Cons(SymAttrID, sx.MakeString("fnref:"+noteID))).Cons(sxhtml.SymAttr) return sx.Nil().Cons(href).Cons(supAttr).Cons(symSUP) }) ev.bind(sz.SymFormatDelete, 1, ev.makeFormatFn(symDEL)) ev.bind(sz.SymFormatEmph, 1, ev.makeFormatFn(symEM)) @@ -686,11 +681,10 @@ } } } return sx.Nil() }) - ev.bind(sz.SymLiteralHTML, 2, ev.evalHTML) ev.bind(sz.SymLiteralInput, 2, func(args sx.Vector, env *Environment) sx.Object { return evalLiteral(args, nil, symKBD, env) }) ev.bind(sz.SymLiteralMath, 2, func(args sx.Vector, env *Environment) sx.Object { a := GetAttributes(args[0], env).AddClass("zs-math") @@ -697,15 +691,13 @@ return evalLiteral(args, a, symCODE, env) }) ev.bind(sz.SymLiteralOutput, 2, func(args sx.Vector, env *Environment) sx.Object { return evalLiteral(args, nil, symSAMP, env) }) - ev.bind(sz.SymLiteralProg, 2, func(args sx.Vector, env *Environment) sx.Object { + ev.bind(sz.SymLiteralCode, 2, func(args sx.Vector, env *Environment) sx.Object { return evalLiteral(args, nil, symCODE, env) }) - - ev.bind(sz.SymLiteralZettel, 0, nilFn) } func (ev *Evaluator) makeFormatFn(sym *sx.Symbol) EvalFn { return func(args sx.Vector, env *Environment) sx.Object { a := GetAttributes(args[0], env) @@ -792,11 +784,11 @@ return sx.Nil() } switch syntax.GetValue() { case "": return sx.Nil() - case api.ValueSyntaxSVG: + case meta.ValueSyntaxSVG: return sx.Nil().Cons(sx.Nil().Cons(data).Cons(sxhtml.SymNoEscape)).Cons(SymP) default: imgAttr := sx.Nil().Cons(sx.Cons(SymAttrSrc, sx.MakeString("data:image/"+syntax.GetValue()+";base64,"+data.GetValue()))) var sb strings.Builder flattenText(&sb, description) @@ -806,12 +798,12 @@ return sx.Nil().Cons(sx.Nil().Cons(imgAttr.Cons(sxhtml.SymAttr)).Cons(SymIMG)).Cons(SymP) } } func flattenText(sb *strings.Builder, lst *sx.Pair) { - for elem := lst; elem != nil; elem = elem.Tail() { - switch obj := elem.Car().(type) { + for elem := range lst.Values() { + switch obj := elem.(type) { case sx.String: sb.WriteString(obj.GetValue()) case *sx.Pair: flattenText(sb, obj) } @@ -881,12 +873,12 @@ } // EvalPairList evaluates a list of lists. func (ev *Evaluator) EvalPairList(pair *sx.Pair, env *Environment) *sx.Pair { var result sx.ListBuilder - for node := pair; node != nil; node = node.Tail() { - elem := ev.Eval(node.Car(), env) + for obj := range pair.Values() { + elem := ev.Eval(obj, env) result.Add(elem) } if env.err == nil { return result.List() } ADDED sz/build.go Index: sz/build.go ================================================================== --- /dev/null +++ sz/build.go @@ -0,0 +1,129 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2025-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client is licensed under the latest version of the EUPL +// (European Union Public License). Please see file LICENSE.txt for your rights +// and obligations under this license. +// +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2025-present Detlef Stern +//----------------------------------------------------------------------------- + +package sz + +import "t73f.de/r/sx" + +// MakeBlock builds a block node. +func MakeBlock(blocks ...*sx.Pair) *sx.Pair { + var lb sx.ListBuilder + lb.Add(SymBlock) + for _, block := range blocks { + lb.Add(block) + } + return lb.List() +} + +// MakeBlockList builds a block node from a list of blocks. +func MakeBlockList(blocks *sx.Pair) *sx.Pair { return blocks.Cons(SymBlock) } + +// MakeInlineList builds an inline node from a list of inlines. +func MakeInlineList(inlines *sx.Pair) *sx.Pair { return inlines.Cons(SymInline) } + +// MakePara builds a paragraph node. +func MakePara(inlines *sx.Pair) *sx.Pair { return inlines.Cons(SymPara) } + +// MakeVerbatim builds a node for verbatim text. +func MakeVerbatim(sym *sx.Symbol, attrs *sx.Pair, content string) *sx.Pair { + return sx.MakeList(sym, attrs, sx.MakeString(content)) +} + +// MakeRegion builds a region node. +func MakeRegion(sym *sx.Symbol, attrs *sx.Pair, blocks *sx.Pair, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(blocks).Cons(attrs).Cons(sym) +} + +// MakeHeading builds a heading node. +func MakeHeading(level int, attrs, text *sx.Pair, slug, fragment string) *sx.Pair { + return text. + Cons(sx.MakeString(fragment)). + Cons(sx.MakeString(slug)). + Cons(attrs). + Cons(sx.Int64(level)). + Cons(SymHeading) +} + +// MakeThematic builds a node to implement a thematic break. +func MakeThematic(attrs *sx.Pair) *sx.Pair { + return sx.Cons(SymThematic, sx.Cons(attrs, sx.Nil())) +} + +// MakeCell builds a table cell node. +func MakeCell(sym *sx.Symbol, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(sym) +} + +// MakeTransclusion builds a transclusion node. +func MakeTransclusion(attrs *sx.Pair, ref *sx.Pair, text *sx.Pair) *sx.Pair { + return text.Cons(ref).Cons(attrs).Cons(SymTransclude) +} + +// MakeBLOB builds a block BLOB node. +func MakeBLOB(description *sx.Pair, syntax, content string) *sx.Pair { + return sx.Cons(SymBLOB, + sx.Cons(description, + sx.Cons(sx.MakeString(syntax), + sx.Cons(sx.MakeString(content), sx.Nil())))) +} + +// MakeText builds a text node. +func MakeText(text string) *sx.Pair { + return sx.Cons(SymText, sx.Cons(sx.MakeString(text), sx.Nil())) +} + +// MakeSoft builds a node for a soft line break. +func MakeSoft() *sx.Pair { return sx.Cons(SymSoft, sx.Nil()) } + +// MakeHard builds a node for a hard line break. +func MakeHard() *sx.Pair { return sx.Cons(SymHard, sx.Nil()) } + +// MakeLink builds a link node. +func MakeLink(sym *sx.Symbol, attrs *sx.Pair, ref string, text *sx.Pair) *sx.Pair { + return text.Cons(sx.MakeString(ref)).Cons(attrs).Cons(sym) +} + +// MakeEmbed builds a embed node. +func MakeEmbed(attrs *sx.Pair, ref sx.Object, syntax string, text *sx.Pair) *sx.Pair { + return text.Cons(sx.MakeString(syntax)).Cons(ref).Cons(attrs).Cons(SymEmbed) +} + +// MakeEmbedBLOB builds an embedded inline BLOB node. +func MakeEmbedBLOB(attrs *sx.Pair, syntax string, content string, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(sx.MakeString(content)).Cons(sx.MakeString(syntax)).Cons(attrs).Cons(SymEmbedBLOB) +} + +// MakeCite builds a node that specifies a citation. +func MakeCite(attrs *sx.Pair, text string, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(sx.MakeString(text)).Cons(attrs).Cons(SymCite) +} + +// MakeEndnote builds an endnote node. +func MakeEndnote(attrs, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(attrs).Cons(SymEndnote) +} + +// MakeMark builds a mark note. +func MakeMark(mark string, slug, fragment string, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(sx.MakeString(fragment)).Cons(sx.MakeString(slug)).Cons(sx.MakeString(mark)).Cons(SymMark) +} + +// MakeFormat builds an inline formatting node. +func MakeFormat(sym *sx.Symbol, attrs, inlines *sx.Pair) *sx.Pair { + return inlines.Cons(attrs).Cons(sym) +} + +// MakeLiteral builds a inline node with literal text. +func MakeLiteral(sym *sx.Symbol, attrs *sx.Pair, text string) *sx.Pair { + return sx.Cons(sym, sx.Cons(attrs, sx.Cons(sx.MakeString(text), sx.Nil()))) +} Index: sz/const.go ================================================================== --- sz/const.go +++ sz/const.go @@ -54,17 +54,15 @@ SymLinkQuery = sx.MakeSymbol("LINK-QUERY") SymLinkExternal = sx.MakeSymbol("LINK-EXTERNAL") SymListOrdered = sx.MakeSymbol("ORDERED") SymListUnordered = sx.MakeSymbol("UNORDERED") SymListQuote = sx.MakeSymbol("QUOTATION") - SymLiteralProg = sx.MakeSymbol("LITERAL-CODE") + SymLiteralCode = sx.MakeSymbol("LITERAL-CODE") SymLiteralComment = sx.MakeSymbol("LITERAL-COMMENT") - SymLiteralHTML = sx.MakeSymbol("LITERAL-HTML") SymLiteralInput = sx.MakeSymbol("LITERAL-INPUT") SymLiteralMath = sx.MakeSymbol("LITERAL-MATH") SymLiteralOutput = sx.MakeSymbol("LITERAL-OUTPUT") - SymLiteralZettel = sx.MakeSymbol("LITERAL-ZETTEL") SymMark = sx.MakeSymbol("MARK") SymPara = sx.MakeSymbol("PARA") SymRegionBlock = sx.MakeSymbol("REGION-BLOCK") SymRegionQuote = sx.MakeSymbol("REGION-QUOTE") SymRegionVerse = sx.MakeSymbol("REGION-VERSE") @@ -72,15 +70,15 @@ SymTable = sx.MakeSymbol("TABLE") SymText = sx.MakeSymbol("TEXT") SymThematic = sx.MakeSymbol("THEMATIC") SymTransclude = sx.MakeSymbol("TRANSCLUDE") SymUnknown = sx.MakeSymbol("UNKNOWN-NODE") + SymVerbatimCode = sx.MakeSymbol("VERBATIM-CODE") SymVerbatimComment = sx.MakeSymbol("VERBATIM-COMMENT") SymVerbatimEval = sx.MakeSymbol("VERBATIM-EVAL") SymVerbatimHTML = sx.MakeSymbol("VERBATIM-HTML") SymVerbatimMath = sx.MakeSymbol("VERBATIM-MATH") - SymVerbatimProg = sx.MakeSymbol("VERBATIM-CODE") SymVerbatimZettel = sx.MakeSymbol("VERBATIM-ZETTEL") // Constant symbols for reference states. SymRefStateInvalid = sx.MakeSymbol("INVALID") SymRefStateZettel = sx.MakeSymbol("ZETTEL") @@ -91,17 +89,16 @@ SymRefStateBased = sx.MakeSymbol("BASED") SymRefStateQuery = sx.MakeSymbol("QUERY") SymRefStateExternal = sx.MakeSymbol("EXTERNAL") // Symbols for metadata types. - SymTypeCredential = sx.MakeSymbol("CREDENTIAL") - SymTypeEmpty = sx.MakeSymbol("EMPTY-STRING") - SymTypeID = sx.MakeSymbol("ZID") - SymTypeIDSet = sx.MakeSymbol("ZID-SET") - SymTypeNumber = sx.MakeSymbol("NUMBER") - SymTypeString = sx.MakeSymbol("STRING") - SymTypeTagSet = sx.MakeSymbol("TAG-SET") - SymTypeTimestamp = sx.MakeSymbol("TIMESTAMP") - SymTypeURL = sx.MakeSymbol("URL") - SymTypeWord = sx.MakeSymbol("WORD") - SymTypeZettelmarkup = sx.MakeSymbol("ZETTELMARKUP") + SymTypeCredential = sx.MakeSymbol("CREDENTIAL") + SymTypeEmpty = sx.MakeSymbol("EMPTY-STRING") + SymTypeID = sx.MakeSymbol("ZID") + SymTypeIDSet = sx.MakeSymbol("ZID-SET") + SymTypeNumber = sx.MakeSymbol("NUMBER") + SymTypeString = sx.MakeSymbol("STRING") + SymTypeTagSet = sx.MakeSymbol("TAG-SET") + SymTypeTimestamp = sx.MakeSymbol("TIMESTAMP") + SymTypeURL = sx.MakeSymbol("URL") + SymTypeWord = sx.MakeSymbol("WORD") ) Index: sz/parser.go ================================================================== --- sz/parser.go +++ sz/parser.go @@ -13,11 +13,11 @@ package sz import ( "t73f.de/r/sx" - "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/meta" "t73f.de/r/zsc/input" ) // --- Contains some simple parsers @@ -24,44 +24,21 @@ // ---- Syntax: none // ParseNoneBlocks parses no block. func ParseNoneBlocks(*input.Input) *sx.Pair { return nil } -// ParseNoneInlines skips to the end of line and parses no inline. -func ParseNoneInlines(inp *input.Input) *sx.Pair { - inp.SkipToEOL() - return nil -} - // ---- Some plain text syntaxes // ParsePlainBlocks parses the block as plain text with the given syntax. func ParsePlainBlocks(inp *input.Input, syntax string) *sx.Pair { var sym *sx.Symbol - if syntax == api.ValueSyntaxHTML { + if syntax == meta.ValueSyntaxHTML { sym = SymVerbatimHTML } else { - sym = SymVerbatimProg + sym = SymVerbatimCode } return sx.MakeList( sym, sx.MakeList(sx.Cons(sx.MakeString(""), sx.MakeString(syntax))), sx.MakeString(string(inp.ScanLineContent())), ) } - -// ParsePlainInlines parses the inline as plain text with the given syntax. -func ParsePlainInlines(inp *input.Input, syntax string) *sx.Pair { - var sym *sx.Symbol - if syntax == api.ValueSyntaxHTML { - sym = SymLiteralHTML - } else { - sym = SymLiteralProg - } - pos := inp.Pos - inp.SkipToEOL() - return sx.MakeList( - sym, - sx.MakeList(sx.Cons(sx.MakeString(""), sx.MakeString(syntax))), - sx.MakeString(string(inp.Src[pos:inp.Pos])), - ) -} Index: sz/parser_test.go ================================================================== --- sz/parser_test.go +++ sz/parser_test.go @@ -24,49 +24,30 @@ if got := sz.ParseNoneBlocks(nil); got != nil { t.Error("GOTB", got) } inp := input.NewInput([]byte("1234\n6789")) - if got := sz.ParseNoneInlines(inp); got != nil { + if got := sz.ParseNoneBlocks(inp); got != nil { t.Error("GOTI", got) } - if got := inp.Pos; got != 4 { - t.Errorf("input should be on position 4, but is %d", got) - } - if got := inp.Ch; got != '\n' { - t.Errorf("input character should be 10, but is %d", got) - } } func TestParsePlani(t *testing.T) { testcases := []struct { - src string - syntax string - expBlocks string - expInlines string + src string + syntax string + expBlocks string }{ - {"abc", "html", - "(VERBATIM-HTML ((\"\" . \"html\")) \"abc\")", - "(LITERAL-HTML ((\"\" . \"html\")) \"abc\")"}, - {"abc\ndef", "html", - "(VERBATIM-HTML ((\"\" . \"html\")) \"abc\\ndef\")", - "(LITERAL-HTML ((\"\" . \"html\")) \"abc\")"}, - {"abc", "text", - "(VERBATIM-CODE ((\"\" . \"text\")) \"abc\")", - "(LITERAL-CODE ((\"\" . \"text\")) \"abc\")"}, - {"abc\nDEF", "text", - "(VERBATIM-CODE ((\"\" . \"text\")) \"abc\\nDEF\")", - "(LITERAL-CODE ((\"\" . \"text\")) \"abc\")"}, + {"abc", "html", "(VERBATIM-HTML ((\"\" . \"html\")) \"abc\")"}, + {"abc\ndef", "html", "(VERBATIM-HTML ((\"\" . \"html\")) \"abc\\ndef\")"}, + {"abc", "text", "(VERBATIM-CODE ((\"\" . \"text\")) \"abc\")"}, + {"abc\nDEF", "text", "(VERBATIM-CODE ((\"\" . \"text\")) \"abc\\nDEF\")"}, } for i, tc := range testcases { t.Run(tc.syntax+":"+tc.src, func(t *testing.T) { inp := input.NewInput([]byte(tc.src)) if got := sz.ParsePlainBlocks(inp, tc.syntax).String(); tc.expBlocks != got { t.Errorf("%d: %q/%v\nexpected: %q\ngot : %q", i, tc.src, tc.syntax, tc.expBlocks, got) } - inp.SetPos(0) - if got := sz.ParsePlainInlines(inp, tc.syntax).String(); tc.expInlines != got { - t.Errorf("%d: %q/%v\nexpected: %q\ngot : %q", i, tc.src, tc.syntax, tc.expInlines, got) - } }) } } Index: sz/sz.go ================================================================== --- sz/sz.go +++ sz/sz.go @@ -19,12 +19,12 @@ "t73f.de/r/zsc/attrs" ) // GetAttributes traverses a s-expression list and returns an attribute structure. func GetAttributes(seq *sx.Pair) (result attrs.Attributes) { - for elem := seq; elem != nil; elem = elem.Tail() { - pair, isPair := sx.GetPair(elem.Car()) + for obj := range seq.Values() { + pair, isPair := sx.GetPair(obj) if !isPair || pair == nil { continue } key := pair.Car() if !key.IsAtom() { @@ -92,11 +92,11 @@ lst, isList := sx.GetPair(obj) if !isList || !lst.Car().IsEqual(SymMeta) { return nil } result := make(map[string]MetaValue) - for node := lst.Tail(); node != nil; node = node.Tail() { + for node := range lst.Tail().Pairs() { if mv, found := makeMetaValue(node.Head()); found { result[mv.Key] = mv } } return result @@ -136,16 +136,12 @@ } } return nil } -// MapRefStateToLinkEmbed maps a reference state symbol to a link symbol or to -// an embed symbol, depending on 'forLink'. -func MapRefStateToLinkEmbed(symRefState *sx.Symbol, forLink bool) *sx.Symbol { - if !forLink { - return SymEmbed - } +// MapRefStateToLink maps a reference state symbol to a link symbol. +func MapRefStateToLink(symRefState *sx.Symbol) *sx.Symbol { if sym, found := mapRefStateLink[symRefState]; found { return sym } return SymLinkInvalid } Index: sz/walk.go ================================================================== --- sz/walk.go +++ sz/walk.go @@ -11,33 +11,35 @@ // SPDX-FileCopyrightText: 2024-present Detlef Stern //----------------------------------------------------------------------------- package sz -import "t73f.de/r/sx" +import ( + "t73f.de/r/sx" +) // Visitor is walking the sx-based AST. type Visitor interface { - Visit(node *sx.Pair, env *sx.Pair) sx.Object + VisitBefore(node *sx.Pair, env *sx.Pair) (sx.Object, bool) + VisitAfter(node *sx.Pair, env *sx.Pair) sx.Object } // Walk a sx-based AST through a Visitor. -func Walk(v Visitor, node *sx.Pair, env *sx.Pair) *sx.Pair { +func Walk(v Visitor, node *sx.Pair, env *sx.Pair) sx.Object { if node == nil { return nil } - if result, isPair := sx.GetPair(v.Visit(node, env)); isPair { + if result, ok := v.VisitBefore(node, env); ok { return result } if sym, isSymbol := sx.GetSymbol(node.Car()); isSymbol { if fn, found := mapChildrenWalk[sym]; found { - return fn(v, node, env) + node = fn(v, node, env) } - return node } - panic(node) + return v.VisitAfter(node, env) } var mapChildrenWalk map[*sx.Symbol]func(Visitor, *sx.Pair, *sx.Pair) *sx.Pair func init() { @@ -51,10 +53,15 @@ SymListOrdered: walkChildrenTail, SymListUnordered: walkChildrenTail, SymListQuote: walkChildrenTail, SymDescription: walkChildrenDescription, SymTable: walkChildrenTable, + SymCell: walkChildrenTail, + SymCellCenter: walkChildrenTail, + SymCellLeft: walkChildrenTail, + SymCellRight: walkChildrenTail, + SymTransclude: walkChildrenInlines4, SymInline: walkChildrenTail, SymEndnote: walkChildrenInlines3, SymMark: walkChildrenMark, SymLinkBased: walkChildrenInlines4, @@ -80,11 +87,11 @@ } } func walkChildrenTail(v Visitor, node *sx.Pair, env *sx.Pair) *sx.Pair { hasNil := false - for n := node.Tail(); n != nil; n = n.Tail() { + for n := range node.Tail().Pairs() { obj := Walk(v, n.Head(), env) if sx.IsNil(obj) { hasNil = true } n.SetCar(obj) @@ -106,11 +113,11 @@ return node } func walkChildrenList(v Visitor, lst *sx.Pair, env *sx.Pair) *sx.Pair { hasNil := false - for n := lst; n != nil; n = n.Tail() { + for n := range lst.Pairs() { obj := Walk(v, n.Head(), env) if sx.IsNil(obj) { hasNil = true } n.SetCar(obj) @@ -117,12 +124,11 @@ } if !hasNil { return lst } var result sx.ListBuilder - for n := lst; n != nil; n = n.Tail() { - obj := n.Car() + for obj := range lst.Values() { if !sx.IsNil(obj) { result.Add(obj) } } return result.List() @@ -163,11 +169,11 @@ } return dn } func walkChildrenTable(v Visitor, tn *sx.Pair, env *sx.Pair) *sx.Pair { - for row := tn.Tail(); row != nil; row = row.Tail() { + for row := range tn.Tail().Pairs() { row.SetCar(walkChildrenList(v, row.Head(), env)) } return tn } @@ -189,15 +195,13 @@ // attr := next.Car() next = next.Tail() // ref := next.Car() next = next.Tail() // syntax := next.Car() - next = next.Tail() - if next != nil { - // text := next.Car() - next.SetCar(Walk(v, next.Head(), env)) - } + + // text-list := next.Tail() + next.SetCdr(walkChildrenList(v, next.Tail(), env)) return en } func walkChildrenInlines4(v Visitor, ln *sx.Pair, env *sx.Pair) *sx.Pair { // sym := ln.Car() Index: sz/zmk/block.go ================================================================== --- sz/zmk/block.go +++ sz/zmk/block.go @@ -41,20 +41,20 @@ return nil, false case ':': bn, success = cp.parseColon() case '@', '`', runeModGrave, '%', '~', '$': cp.clearStacked() - bn, success = cp.parseVerbatim() + bn, success = parseVerbatim(inp) case '"', '<': cp.clearStacked() bn, success = cp.parseRegion() case '=': cp.clearStacked() bn, success = cp.parseHeading() case '-': cp.clearStacked() - bn, success = cp.parseHRule() + bn, success = parseHRule(inp) case '*', '#', '>': cp.lastRow = nil cp.descrl = nil bn, success = cp.parseNestedList() case ';': @@ -68,11 +68,11 @@ cp.lists = nil cp.descrl = nil bn, success = cp.parseRow(), true case '{': cp.clearStacked() - bn, success = cp.parseTransclusion() + bn, success = parseTransclusion(inp) } if success { return bn, false } @@ -79,28 +79,36 @@ } inp.SetPos(pos) cp.clearStacked() ins := cp.parsePara() if startsWithSpaceSoftBreak(ins) { - ins = ins[2:] + ins = ins.Tail().Tail() } else if lastPara != nil { lastPair := lastPara.LastPair() - lastPair.ExtendBang(sx.MakeList(ins...)) + lastPair.ExtendBang(ins) return nil, true } - return sx.MakeList(ins...).Cons(sz.SymPara), false + return sz.MakePara(ins), false } -func startsWithSpaceSoftBreak(ins sx.Vector) bool { - if len(ins) < 2 { +func startsWithSpaceSoftBreak(ins *sx.Pair) bool { + if ins == nil { + return false + } + pair0, isPair0 := sx.GetPair(ins.Car()) + if pair0 == nil || !isPair0 { + return false + } + next := ins.Tail() + if next == nil { + return false + } + pair1, isPair1 := sx.GetPair(next.Car()) + if pair1 == nil || !isPair1 { return false } - pair0, isPair0 := sx.GetPair(ins[0]) - pair1, isPair1 := sx.GetPair(ins[0]) - if !isPair0 || !isPair1 { - return false - } + if pair0.Car().IsEqual(sz.SymText) && sz.IsBreakSym(pair1.Car()) { if args := pair0.Tail(); args != nil { if val, isString := sx.GetString(args.Car()); isString { for _, ch := range val.GetValue() { if !input.IsSpace(ch) { @@ -135,59 +143,58 @@ return cp.parseRegion() } return cp.parseDefDescr() } -// parsePara parses paragraphed inline material as a sx.Vector. -func (cp *zmkP) parsePara() (result sx.Vector) { +// parsePara parses paragraphed inline material as a sx List. +func (cp *zmkP) parsePara() *sx.Pair { + var lb sx.ListBuilder for { in := cp.parseInline() if in == nil { - return result + return lb.List() } - result = append(result, in) + lb.Add(in) if sz.IsBreakSym(in.Car()) { ch := cp.inp.Ch switch ch { // Must contain all cases from above switch in parseBlock. case input.EOS, '\n', '\r', '@', '`', runeModGrave, '%', '~', '$', '"', '<', '=', '-', '*', '#', '>', ';', ':', ' ', '|', '{': - return result + return lb.List() } } } } // countDelim read from input until a non-delimiter is found and returns number of delimiter chars. -func (cp *zmkP) countDelim(delim rune) int { - inp := cp.inp +func countDelim(inp *input.Input, delim rune) int { cnt := 0 for inp.Ch == delim { cnt++ inp.Next() } return cnt } // parseVerbatim parses a verbatim block. -func (cp *zmkP) parseVerbatim() (rn *sx.Pair, success bool) { - inp := cp.inp +func parseVerbatim(inp *input.Input) (*sx.Pair, bool) { fch := inp.Ch - cnt := cp.countDelim(fch) + cnt := countDelim(inp, fch) if cnt < 3 { return nil, false } - attrs := cp.parseBlockAttributes() + attrs := parseBlockAttributes(inp) inp.SkipToEOL() if inp.Ch == input.EOS { return nil, false } var sym *sx.Symbol switch fch { case '@': sym = sz.SymVerbatimZettel case '`', runeModGrave: - sym = sz.SymVerbatimProg + sym = sz.SymVerbatimCode case '%': sym = sz.SymVerbatimComment case '~': sym = sz.SymVerbatimEval case '$': @@ -199,14 +206,13 @@ for { inp.EatEOL() posL := inp.Pos switch inp.Ch { case fch: - if cp.countDelim(fch) >= cnt { + if countDelim(inp, fch) >= cnt { inp.SkipToEOL() - rn = sx.MakeList(sym, attrs, sx.MakeString(string(content))) - return rn, true + return sz.MakeVerbatim(sym, attrs, string(content)), true } inp.SetPos(posL) case input.EOS: return nil, false } @@ -217,33 +223,30 @@ content = append(content, inp.Src[posL:inp.Pos]...) } } // parseRegion parses a block region. -func (cp *zmkP) parseRegion() (rn *sx.Pair, success bool) { +func (cp *zmkP) parseRegion() (*sx.Pair, bool) { inp := cp.inp fch := inp.Ch - cnt := cp.countDelim(fch) + cnt := countDelim(inp, fch) if cnt < 3 { return nil, false } var sym *sx.Symbol - oldInVerse := cp.inVerse - defer func() { cp.inVerse = oldInVerse }() switch fch { case ':': sym = sz.SymRegionBlock case '<': sym = sz.SymRegionQuote case '"': sym = sz.SymRegionVerse - cp.inVerse = true default: panic(fmt.Sprintf("%q is not a region char", fch)) } - attrs := cp.parseBlockAttributes() + attrs := parseBlockAttributes(inp) inp.SkipToEOL() if inp.Ch == input.EOS { return nil, false } var blocksBuilder sx.ListBuilder @@ -251,14 +254,13 @@ inp.EatEOL() for { posL := inp.Pos switch inp.Ch { case fch: - if cp.countDelim(fch) >= cnt { + if countDelim(inp, fch) >= cnt { ins := cp.parseRegionLastLine() - rn = ins.Cons(blocksBuilder.List()).Cons(attrs).Cons(sym) - return rn, true + return sz.MakeRegion(sym, attrs, blocksBuilder.List(), ins), true } inp.SetPos(posL) case input.EOS: return nil, false } @@ -290,13 +292,13 @@ region.Add(in) } } // parseHeading parses a head line. -func (cp *zmkP) parseHeading() (hn *sx.Pair, success bool) { +func (cp *zmkP) parseHeading() (*sx.Pair, bool) { inp := cp.inp - delims := cp.countDelim(inp.Ch) + delims := countDelim(inp, inp.Ch) if delims < 3 { return nil, false } if inp.Ch != ' ' { return nil, false @@ -304,57 +306,48 @@ inp.Next() inp.SkipSpace() if delims > 7 { delims = 7 } - level := int64(delims - 2) + level := delims - 2 var attrs *sx.Pair var text sx.ListBuilder for { if input.IsEOLEOS(inp.Ch) { - return createHeading(level, attrs, text.List()), true + return sz.MakeHeading(level, attrs, text.List(), "", ""), true } in := cp.parseInline() if in == nil { - return createHeading(level, attrs, text.List()), true + return sz.MakeHeading(level, attrs, text.List(), "", ""), true } text.Add(in) if inp.Ch == '{' && inp.Peek() != '{' { - attrs = cp.parseBlockAttributes() + attrs = parseBlockAttributes(inp) inp.SkipToEOL() - return createHeading(level, attrs, text.List()), true + return sz.MakeHeading(level, attrs, text.List(), "", ""), true } } } -func createHeading(level int64, attrs, text *sx.Pair) *sx.Pair { - return text. - Cons(sx.MakeString("")). // Fragment - Cons(sx.MakeString("")). // Slug - Cons(attrs). - Cons(sx.Int64(level)). - Cons(sz.SymHeading) -} // parseHRule parses a horizontal rule. -func (cp *zmkP) parseHRule() (hn *sx.Pair, success bool) { - inp := cp.inp - if cp.countDelim(inp.Ch) < 3 { +func parseHRule(inp *input.Input) (*sx.Pair, bool) { + if countDelim(inp, inp.Ch) < 3 { return nil, false } - attrs := cp.parseBlockAttributes() + attrs := parseBlockAttributes(inp) inp.SkipToEOL() - return sx.MakeList(sz.SymThematic, attrs), true + return sz.MakeThematic(attrs), true } // parseNestedList parses a list. -func (cp *zmkP) parseNestedList() (res *sx.Pair, success bool) { - kinds := cp.parseNestedListKinds() +func (cp *zmkP) parseNestedList() (*sx.Pair, bool) { + inp := cp.inp + kinds := parseNestedListKinds(inp) if len(kinds) == 0 { return nil, false } - inp := cp.inp inp.SkipSpace() if !kinds[len(kinds)-1].IsEqual(sz.SymListQuote) && input.IsEOLEOS(inp.Ch) { return nil, false } @@ -361,21 +354,20 @@ if len(kinds) < len(cp.lists) { cp.lists = cp.lists[:len(kinds)] } ln, newLnCount := cp.buildNestedList(kinds) pv := cp.parseLinePara() - bn := sx.Cons(sz.SymBlock, nil) - if len(pv) != 0 { - bn.AppendBang(pv.MakeList().Cons(sz.SymPara)) + bn := sz.MakeBlock() + if pv != nil { + bn.AppendBang(sz.MakePara(pv)) } lastItemPair := ln.LastPair() lastItemPair.AppendBang(bn) return cp.cleanupParsedNestedList(newLnCount) } -func (cp *zmkP) parseNestedListKinds() []*sx.Symbol { - inp := cp.inp +func parseNestedListKinds(inp *input.Input) []*sx.Symbol { result := make([]*sx.Symbol, 0, 8) for { var sym *sx.Symbol switch inp.Ch { case '*': @@ -416,14 +408,14 @@ } } return ln, newLnCount } -func (cp *zmkP) cleanupParsedNestedList(newLnCount int) (res *sx.Pair, success bool) { +func (cp *zmkP) cleanupParsedNestedList(newLnCount int) (*sx.Pair, bool) { childPos := len(cp.lists) - 1 parentPos := childPos - 1 - for i := 0; i < newLnCount; i++ { + for range newLnCount { if parentPos < 0 { return cp.lists[0], true } parentLn := cp.lists[parentPos] childLn := cp.lists[childPos] @@ -431,12 +423,11 @@ // Add list to last item of the parent list lastParent := firstParent.LastPair() lastParent.Head().LastPair().AppendBang(childLn) } else { // Set list to first child of parent. - childBlock := sx.MakeList(sz.SymBlock, cp.lists[childPos]) - parentLn.LastPair().AppendBang(childBlock) + parentLn.LastPair().AppendBang(sz.MakeBlock(cp.lists[childPos])) } childPos-- parentPos-- } return nil, true @@ -455,11 +446,11 @@ descrl = sx.Cons(sz.SymDescription, nil) cp.descrl = descrl res = descrl } lastPair, pos := lastPairPos(descrl) - for { + for first := true; ; first = false { in := cp.parseInline() if in == nil { if pos%2 == 0 { // lastPair is either the empty description list or the last block of definitions return nil, false @@ -469,10 +460,16 @@ } if pos%2 == 0 { // lastPair is either the empty description list or the last block of definitions lastPair = lastPair.AppendBang(sx.Cons(in, nil)) pos++ + } else if first { + // Previous term had no description + lastPair = lastPair. + AppendBang(sz.MakeBlock()). + AppendBang(sx.Cons(in, nil)) + pos += 2 } else { // lastPair is the term part and we need to append the inline list just read lastPair.Head().LastPair().AppendBang(in) } if sz.IsBreakSym(in.Car()) { @@ -488,25 +485,25 @@ return nil, false } inp.Next() inp.SkipSpace() descrl := cp.descrl - lastPair, pos := lastPairPos(descrl) - if descrl == nil || pos <= 0 { + lastPair, lpPos := lastPairPos(descrl) + if descrl == nil || lpPos < 0 { // No term given return nil, false } pn := cp.parseLinePara() - if len(pn) == 0 { + if pn == nil { return nil, false } - newDef := sx.MakeList(sz.SymBlock, pn.MakeList().Cons(sz.SymPara)) - if pos%2 == 1 { + newDef := sz.MakeBlock(sz.MakePara(pn)) + if lpPos%2 == 1 { // Just a term, but no definitions - lastPair.AppendBang(sx.MakeList(sz.SymBlock, newDef)) + lastPair.AppendBang(sz.MakeBlock(newDef)) } else { // lastPara points a the last definition lastPair.Head().LastPair().AppendBang(newDef) } return nil, true @@ -551,21 +548,20 @@ cp.lists = cp.lists[:cnt] if cnt == 0 { return false } pv := cp.parseLinePara() - if len(pv) == 0 { + if pv == nil { return false } ln := cp.lists[cnt-1] lbn := ln.LastPair().Head() lpn := lbn.LastPair().Head() - pvList := pv.MakeList() if lpn.Car().IsEqual(sz.SymPara) { - lpn.LastPair().SetCdr(pvList) + lpn.LastPair().SetCdr(pv) } else { - lbn.LastPair().AppendBang(pvList.Cons(sz.SymPara)) + lbn.LastPair().AppendBang(sz.MakePara(pv)) } return true } func (cp *zmkP) parseIndentForDescription(cnt int) bool { @@ -586,37 +582,58 @@ return true } } } - // Continuation of a definition description + // Continuation of a definition description. + // Either it is a continuation of a definition paragraph, or it is a new paragraph. pn := cp.parseLinePara() - if len(pn) == 0 { + if pn == nil { return false } + bn := lastPair.Head() + + // Check for new paragraph + for curr := bn.Tail(); curr != nil; { + obj := curr.Head() + if obj == nil { + break + } + next := curr.Tail() + if next == nil { + break + } + if symSeparator.IsEqual(next.Head().Car()) { + // It is a new paragraph! + obj.LastPair().AppendBang(sz.MakePara(pn)) + return true + } + curr = next + } + + // Continuation of existing paragraph para := bn.LastPair().Head().LastPair().Head() - pnList := pn.MakeList() if para.Car().IsEqual(sz.SymPara) { - para.LastPair().SetCdr(pnList) + para.LastPair().SetCdr(pn) } else { - bn.LastPair().AppendBang(pnList.Cons(sz.SymPara)) + bn.LastPair().AppendBang(sz.MakePara(pn)) } return true } // parseLinePara parses one paragraph of inline material. -func (cp *zmkP) parseLinePara() sx.Vector { - var ins sx.Vector +func (cp *zmkP) parseLinePara() *sx.Pair { + var lb sx.ListBuilder for { in := cp.parseInline() if in == nil { - return ins + return lb.List() } - ins = append(ins, in) + lb.Add(in) if sz.IsBreakSym(in.Car()) { - return ins + return lb.List() } } } // parseRow parse one table row. @@ -624,11 +641,11 @@ inp := cp.inp if inp.Peek() == '%' { inp.SkipToEOL() return nil } - //var row, curr *sx.Pair + var row sx.ListBuilder for { inp.Next() cell := cp.parseCell() if cell != nil { @@ -661,27 +678,26 @@ for { if input.IsEOLEOS(inp.Ch) { if cell.IsEmpty() { return nil } - return cell.List().Cons(sz.SymCell) + return sz.MakeCell(sz.SymCell, cell.List()) } if inp.Ch == '|' { - return cell.List().Cons(sz.SymCell) + return sz.MakeCell(sz.SymCell, cell.List()) } in := cp.parseInline() cell.Add(in) } } // parseTransclusion parses '{' '{' '{' ZID '}' '}' '}' -func (cp *zmkP) parseTransclusion() (*sx.Pair, bool) { - if cp.countDelim('{') != 3 { +func parseTransclusion(inp *input.Input) (*sx.Pair, bool) { + if countDelim(inp, '{') != 3 { return nil, false } - inp := cp.inp posA, posE := inp.Pos, 0 loop: for { @@ -711,11 +727,11 @@ break loop } inp.Next() } inp.Next() // consume last '}' - a := cp.parseBlockAttributes() + attrs := parseBlockAttributes(inp) inp.SkipToEOL() refText := string(inp.Src[posA:posE]) ref := ParseReference(refText) - return sx.MakeList(sz.SymTransclude, a, ref), true + return sz.MakeTransclusion(attrs, ref, sx.Nil()), true } Index: sz/zmk/inline.go ================================================================== --- sz/zmk/inline.go +++ sz/zmk/inline.go @@ -13,10 +13,11 @@ package zmk import ( "fmt" + "slices" "strings" "t73f.de/r/sx" "t73f.de/r/zsc/api" "t73f.de/r/zsc/input" @@ -34,133 +35,133 @@ success := false switch inp.Ch { case input.EOS: return nil case '\n', '\r': - return cp.parseSoftBreak() + return parseSoftBreak(inp) case '[': switch inp.Next() { case '[': - in, success = cp.parseLinkEmbed('[', ']', true) + in, success = cp.parseLink('[', ']') case '@': in, success = cp.parseCite() case '^': in, success = cp.parseEndnote() case '!': in, success = cp.parseMark() } case '{': if inp.Next() == '{' { - in, success = cp.parseLinkEmbed('{', '}', false) + in, success = cp.parseEmbed('{', '}') } case '%': - in, success = cp.parseComment() + in, success = parseComment(inp) case '_', '*', '>', '~', '^', ',', '"', '#', ':': in, success = cp.parseFormat() - case '@', '\'', '`', '=', runeModGrave: - in, success = cp.parseLiteral() + case '\'', '`', '=', runeModGrave: + in, success = parseLiteral(inp) case '$': - in, success = cp.parseLiteralMath() + in, success = parseLiteralMath(inp) case '\\': - return cp.parseBackslash() + return parseBackslash(inp) case '-': - in, success = cp.parseNdash() + in, success = parseNdash(inp) case '&': - in, success = cp.parseEntity() + in, success = parseEntity(inp) } if success { return in } } inp.SetPos(pos) - return cp.parseText() + return parseText(inp) } -func (cp *zmkP) parseText() *sx.Pair { - return sx.MakeList(sz.SymText, cp.parseString()) -} +func parseText(inp *input.Input) *sx.Pair { return sz.MakeText(parseString(inp)) } -func (cp *zmkP) parseString() sx.String { - inp := cp.inp +func parseString(inp *input.Input) string { pos := inp.Pos if inp.Ch == '\\' { - cp.inp.Next() - return cp.parseBackslashRest() + inp.Next() + return parseBackslashRest(inp) } for { switch inp.Next() { // The following case must contain all runes that occur in parseInline! // Plus the closing brackets ] and } and ) and the middle | - case input.EOS, '\n', '\r', '[', ']', '{', '}', '(', ')', '|', '%', '_', '*', '>', '~', '^', ',', '"', '#', ':', '\'', '@', '`', runeModGrave, '$', '=', '\\', '-', '&': - return sx.MakeString(string(inp.Src[pos:inp.Pos])) + case input.EOS, '\n', '\r', '[', ']', '{', '}', '(', ')', '|', '%', '_', '*', '>', '~', '^', ',', '"', '#', ':', '\'', '`', runeModGrave, '$', '=', '\\', '-', '&': + return string(inp.Src[pos:inp.Pos]) } } } -func (cp *zmkP) parseBackslash() *sx.Pair { - inp := cp.inp +func parseBackslash(inp *input.Input) *sx.Pair { switch inp.Next() { case '\n', '\r': inp.EatEOL() - return sx.MakeList(sz.SymHard) + return sz.MakeHard() default: - return sx.MakeList(sz.SymText, cp.parseBackslashRest()) + return sz.MakeText(parseBackslashRest(inp)) } } -func (cp *zmkP) parseBackslashRest() sx.String { - inp := cp.inp +func parseBackslashRest(inp *input.Input) string { if input.IsEOLEOS(inp.Ch) { - return sx.MakeString("\\") + return "\\" } if inp.Ch == ' ' { inp.Next() - return sx.MakeString("\u00a0") + return "\u00a0" } pos := inp.Pos inp.Next() - return sx.MakeString(string(inp.Src[pos:inp.Pos])) + return string(inp.Src[pos:inp.Pos]) } -func (cp *zmkP) parseSoftBreak() *sx.Pair { - cp.inp.EatEOL() - return sx.MakeList(sz.SymSoft) +func parseSoftBreak(inp *input.Input) *sx.Pair { + inp.EatEOL() + return sz.MakeSoft() } -func (cp *zmkP) parseLinkEmbed(openCh, closeCh rune, forLink bool) (*sx.Pair, bool) { +func (cp *zmkP) parseLink(openCh, closeCh rune) (*sx.Pair, bool) { if refString, text, ok := cp.parseReference(openCh, closeCh); ok { - attrs := cp.parseInlineAttributes() + attrs := parseInlineAttributes(cp.inp) if len(refString) > 0 { ref := ParseReference(refString) refSym, _ := sx.GetSymbol(ref.Car()) - sym := sz.MapRefStateToLinkEmbed(refSym, forLink) - ln := text. - Cons(ref.Tail().Car()). // reference value - Cons(attrs). - Cons(sym) - return ln, true + sym := sz.MapRefStateToLink(refSym) + return sz.MakeLink(sym, attrs, ref.Tail().Car().(sx.String).GetValue(), text), true + } + } + return nil, false +} +func (cp *zmkP) parseEmbed(openCh, closeCh rune) (*sx.Pair, bool) { + if refString, text, ok := cp.parseReference(openCh, closeCh); ok { + attrs := parseInlineAttributes(cp.inp) + if len(refString) > 0 { + return sz.MakeEmbed(attrs, ParseReference(refString), "", text), true } } return nil, false } func hasQueryPrefix(src []byte) bool { return len(src) > len(api.QueryPrefix) && string(src[:len(api.QueryPrefix)]) == api.QueryPrefix } -func (cp *zmkP) parseReference(openCh, closeCh rune) (ref string, text *sx.Pair, _ bool) { +func (cp *zmkP) parseReference(openCh, closeCh rune) (string, *sx.Pair, bool) { inp := cp.inp inp.Next() inp.SkipSpace() if inp.Ch == openCh { // Additional opening chars result in a fail return "", nil, false } - var is sx.Vector + var lb sx.ListBuilder pos := inp.Pos if !hasQueryPrefix(inp.Src[pos:]) { - hasSpace, ok := cp.readReferenceToSep(closeCh) + hasSpace, ok := readReferenceToSep(inp, closeCh) if !ok { return "", nil, false } if inp.Ch == '|' { // First part must be inline text if pos == inp.Pos { // [[| or {{| @@ -170,11 +171,11 @@ for { in := cp.parseInline() if in == nil { break } - is = append(is, in) + lb.Add(in) } cp.inp = inp inp.Next() } else { if hasSpace { @@ -184,27 +185,23 @@ } } inp.SkipSpace() pos = inp.Pos - if !cp.readReferenceToClose(closeCh) { + if !readReferenceToClose(inp, closeCh) { return "", nil, false } - ref = strings.TrimSpace(string(inp.Src[pos:inp.Pos])) + ref := strings.TrimSpace(string(inp.Src[pos:inp.Pos])) if inp.Next() != closeCh { return "", nil, false } inp.Next() - if len(is) == 0 { - return ref, nil, true - } - return ref, sx.MakeList(is...), true + return ref, lb.List(), true } -func (cp *zmkP) readReferenceToSep(closeCh rune) (bool, bool) { +func readReferenceToSep(inp *input.Input, closeCh rune) (bool, bool) { hasSpace := false - inp := cp.inp for { switch inp.Ch { case input.EOS: return false, false case '\n', '\r', ' ': @@ -231,12 +228,11 @@ } inp.Next() } } -func (cp *zmkP) readReferenceToClose(closeCh rune) bool { - inp := cp.inp +func readReferenceToClose(inp *input.Input, closeCh rune) bool { pos := inp.Pos for { switch inp.Ch { case input.EOS: return false @@ -280,23 +276,22 @@ } ins, ok := cp.parseLinkLikeRest() if !ok { return nil, false } - attrs := cp.parseInlineAttributes() - cn := ins.Cons(sx.MakeString(string(inp.Src[pos:posL]))).Cons(attrs).Cons(sz.SymCite) - return cn, true + attrs := parseInlineAttributes(inp) + return sz.MakeCite(attrs, string(inp.Src[pos:posL]), ins), true } func (cp *zmkP) parseEndnote() (*sx.Pair, bool) { cp.inp.Next() ins, ok := cp.parseLinkLikeRest() if !ok { return nil, false } - attrs := cp.parseInlineAttributes() - return ins.Cons(attrs).Cons(sz.SymEndnote), true + attrs := parseInlineAttributes(cp.inp) + return sz.MakeEndnote(attrs, ins), true } func (cp *zmkP) parseMark() (*sx.Pair, bool) { inp := cp.inp inp.Next() @@ -305,11 +300,11 @@ if !isNameRune(inp.Ch) { return nil, false } inp.Next() } - mark := inp.Src[pos:inp.Pos] + mark := string(inp.Src[pos:inp.Pos]) var ins *sx.Pair if inp.Ch == '|' { inp.Next() var ok bool ins, ok = cp.parseLinkLikeRest() @@ -317,59 +312,46 @@ return nil, false } } else { inp.Next() } - mn := ins. - Cons(sx.MakeString("")). // Fragment - Cons(sx.MakeString("")). // Slug - Cons(sx.MakeString(string(mark))). - Cons(sz.SymMark) - return mn, true + return sz.MakeMark(mark, "", "", ins), true // Problematisch ist, dass hier noch nicht mn.Fragment und mn.Slug gesetzt werden. // Evtl. muss es ein PreMark-Symbol geben } func (cp *zmkP) parseLinkLikeRest() (*sx.Pair, bool) { - var ins sx.Vector + var ins sx.ListBuilder inp := cp.inp inp.SkipSpace() for inp.Ch != ']' { in := cp.parseInline() if in == nil { return nil, false } - ins = append(ins, in) + ins.Add(in) if input.IsEOLEOS(inp.Ch) && sz.IsBreakSym(in.Car()) { return nil, false } } inp.Next() - if len(ins) == 0 { - return nil, true - } - return sx.MakeList(ins...), true + return ins.List(), true } -func (cp *zmkP) parseComment() (res *sx.Pair, success bool) { - inp := cp.inp +func parseComment(inp *input.Input) (*sx.Pair, bool) { if inp.Next() != '%' { return nil, false } for inp.Ch == '%' { inp.Next() } - attrs := cp.parseInlineAttributes() + attrs := parseInlineAttributes(inp) inp.SkipSpace() pos := inp.Pos for { if input.IsEOLEOS(inp.Ch) { - return sx.MakeList( - sz.SymLiteralComment, - attrs, - sx.MakeString(string(inp.Src[pos:inp.Pos])), - ), true + return sz.MakeLiteral(sz.SymLiteralComment, attrs, string(inp.Src[pos:inp.Pos])), true } inp.Next() } } @@ -383,11 +365,11 @@ '"': sz.SymFormatQuote, '#': sz.SymFormatMark, ':': sz.SymFormatSpan, } -func (cp *zmkP) parseFormat() (res *sx.Pair, success bool) { +func (cp *zmkP) parseFormat() (*sx.Pair, bool) { inp := cp.inp fch := inp.Ch symFormat, ok := mapRuneFormat[fch] if !ok { panic(fmt.Sprintf("%q is not a formatting char", fch)) @@ -395,43 +377,40 @@ // read 2nd formatting character if inp.Next() != fch { return nil, false } inp.Next() - var inlines sx.Vector + var inlines sx.ListBuilder for { if inp.Ch == input.EOS { return nil, false } if inp.Ch == fch { if inp.Next() == fch { inp.Next() - attrs := cp.parseInlineAttributes() - fn := sx.MakeList(inlines...).Cons(attrs).Cons(symFormat) - return fn, true + attrs := parseInlineAttributes(inp) + return sz.MakeFormat(symFormat, attrs, inlines.List()), true } - inlines = append(inlines, sx.MakeList(sz.SymText, sx.MakeString(string(fch)))) + inlines.Add(sz.MakeText(string(fch))) } else if in := cp.parseInline(); in != nil { if input.IsEOLEOS(inp.Ch) && sz.IsBreakSym(in.Car()) { return nil, false } - inlines = append(inlines, in) + inlines.Add(in) } } } var mapRuneLiteral = map[rune]*sx.Symbol{ - '@': sz.SymLiteralZettel, - '`': sz.SymLiteralProg, - runeModGrave: sz.SymLiteralProg, + '`': sz.SymLiteralCode, + runeModGrave: sz.SymLiteralCode, '\'': sz.SymLiteralInput, '=': sz.SymLiteralOutput, // No '$': sz.SymLiteralMath, because pairing literal math is a little different } -func (cp *zmkP) parseLiteral() (res *sx.Pair, success bool) { - inp := cp.inp +func parseLiteral(inp *input.Input) (*sx.Pair, bool) { fch := inp.Ch symLiteral, ok := mapRuneLiteral[fch] if !ok { panic(fmt.Sprintf("%q is not a formatting char", fch)) } @@ -447,35 +426,22 @@ } if inp.Ch == fch { if inp.Peek() == fch { inp.Next() inp.Next() - return createLiteralNode(symLiteral, cp.parseInlineAttributes(), sb.String()), true + return sz.MakeLiteral(symLiteral, parseInlineAttributes(inp), sb.String()), true } sb.WriteRune(fch) inp.Next() } else { - s := cp.parseString() - sb.WriteString(s.GetValue()) + s := parseString(inp) + sb.WriteString(s) } } } -func createLiteralNode(sym *sx.Symbol, attrs *sx.Pair, content string) *sx.Pair { - if sym.IsEqualSymbol(sz.SymLiteralZettel) { - if p := attrs.Assoc(sx.MakeString("")); p != nil { - if val, isString := sx.GetString(p.Cdr()); isString && val.GetValue() == api.ValueSyntaxHTML { - sym = sz.SymLiteralHTML - attrs = attrs.RemoveAssoc(sx.MakeString("")) - } - } - } - return sx.MakeList(sym, attrs, sx.MakeString(content)) -} - -func (cp *zmkP) parseLiteralMath() (res *sx.Pair, success bool) { - inp := cp.inp +func parseLiteralMath(inp *input.Input) (res *sx.Pair, success bool) { // read 2nd formatting character if inp.Next() != '$' { return nil, false } inp.Next() @@ -483,31 +449,29 @@ for { if inp.Ch == input.EOS { return nil, false } if inp.Ch == '$' && inp.Peek() == '$' { - content := append([]byte{}, inp.Src[pos:inp.Pos]...) + content := slices.Clone(inp.Src[pos:inp.Pos]) inp.Next() inp.Next() - fn := sx.MakeList(sz.SymLiteralMath, cp.parseInlineAttributes(), sx.MakeString(string(content))) - return fn, true + return sz.MakeLiteral(sz.SymLiteralMath, parseInlineAttributes(inp), string(content)), true } inp.Next() } } -func (cp *zmkP) parseNdash() (res *sx.Pair, success bool) { - inp := cp.inp +func parseNdash(inp *input.Input) (*sx.Pair, bool) { if inp.Peek() != inp.Ch { return nil, false } inp.Next() inp.Next() - return sx.MakeList(sz.SymText, sx.MakeString("\u2013")), true + return sz.MakeText("\u2013"), true } -func (cp *zmkP) parseEntity() (res *sx.Pair, success bool) { - if text, ok := cp.inp.ScanEntity(); ok { - return sx.MakeList(sz.SymText, sx.MakeString(text)), true +func parseEntity(inp *input.Input) (*sx.Pair, bool) { + if text, ok := inp.ScanEntity(); ok { + return sz.MakeText(text), true } return nil, false } Index: sz/zmk/post-processor.go ================================================================== --- sz/zmk/post-processor.go +++ sz/zmk/post-processor.go @@ -23,28 +23,30 @@ var symInVerse = sx.MakeSymbol("in-verse") var symNoBlock = sx.MakeSymbol("no-block") type postProcessor struct{} -func (pp *postProcessor) Visit(lst *sx.Pair, env *sx.Pair) sx.Object { +func (pp *postProcessor) VisitBefore(lst *sx.Pair, env *sx.Pair) (sx.Object, bool) { if lst == nil { - return nil + return nil, true } sym, isSym := sx.GetSymbol(lst.Car()) if !isSym { panic(lst) } if fn, found := symMap[sym]; found { - return fn(pp, lst, env) + return fn(pp, lst, env), true } - return sx.Int64(0) + return nil, false } + +func (pp *postProcessor) VisitAfter(lst *sx.Pair, _ *sx.Pair) sx.Object { return lst } func (pp *postProcessor) visitPairList(lst *sx.Pair, env *sx.Pair) *sx.Pair { var pList sx.ListBuilder - for node := lst; node != nil; node = node.Tail() { - if elem := sz.Walk(pp, node.Head(), env); elem != nil { + for node := range lst.Pairs() { + if elem, isPair := sx.GetPair(sz.Walk(pp, node.Head(), env)); isPair && elem != nil { pList.Add(elem) } } return pList.List() } @@ -59,11 +61,11 @@ sz.SymRegionQuote: postProcessRegion, sz.SymRegionVerse: postProcessRegionVerse, sz.SymVerbatimComment: postProcessVerbatim, sz.SymVerbatimEval: postProcessVerbatim, sz.SymVerbatimMath: postProcessVerbatim, - sz.SymVerbatimProg: postProcessVerbatim, + sz.SymVerbatimCode: postProcessVerbatim, sz.SymVerbatimZettel: postProcessVerbatim, sz.SymHeading: postProcessHeading, sz.SymListOrdered: postProcessItemList, sz.SymListUnordered: postProcessItemList, sz.SymListQuote: postProcessQuoteList, @@ -82,11 +84,11 @@ sz.SymLinkHosted: postProcessInlines4, sz.SymLinkInvalid: postProcessInlines4, sz.SymLinkQuery: postProcessInlines4, sz.SymLinkSelf: postProcessInlines4, sz.SymLinkZettel: postProcessInlines4, - sz.SymEmbed: postProcessInlines4, + sz.SymEmbed: postProcessEmbed, sz.SymCite: postProcessInlines4, sz.SymFormatDelete: postProcessFormat, sz.SymFormatEmph: postProcessFormat, sz.SymFormatInsert: postProcessFormat, sz.SymFormatMark: postProcessFormat, @@ -127,21 +129,20 @@ func postProcessRegionVerse(pp *postProcessor, rn *sx.Pair, env *sx.Pair) *sx.Pair { return doPostProcessRegion(pp, rn, env.Cons(sx.Cons(symInVerse, nil)), env) } func doPostProcessRegion(pp *postProcessor, rn *sx.Pair, envBlock, envInline *sx.Pair) *sx.Pair { - - sym := rn.Car() + sym := rn.Car().(*sx.Symbol) next := rn.Tail() - attrs := next.Car() + attrs := next.Car().(*sx.Pair) next = next.Tail() blocks := pp.visitPairList(next.Head(), envBlock) text := pp.visitInlines(next.Tail(), envInline) if blocks == nil && text == nil { return nil } - return text.Cons(blocks).Cons(attrs).Cons(sym) + return sz.MakeRegion(sym, attrs, blocks, text) } func postProcessVerbatim(_ *postProcessor, verb *sx.Pair, _ *sx.Pair) *sx.Pair { if content, isString := sx.GetString(verb.Tail().Tail().Car()); isString && content.GetValue() != "" { return verb @@ -148,21 +149,20 @@ } return nil } func postProcessHeading(pp *postProcessor, hn *sx.Pair, env *sx.Pair) *sx.Pair { - sym := hn.Car() - next := hn.Tail() - level := next.Car() - next = next.Tail() - attrs := next.Car() - next = next.Tail() - slug := next.Car() - next = next.Tail() - fragment := next.Car() - if text := pp.visitInlines(next.Tail(), env); text != nil { - return text.Cons(fragment).Cons(slug).Cons(attrs).Cons(level).Cons(sym) + next := hn.Tail() + level := next.Car().(sx.Int64) + next = next.Tail() + attrs := next.Car().(*sx.Pair) + next = next.Tail() + slug := next.Car().(sx.String) + next = next.Tail() + fragment := next.Car().(sx.String) + if text := pp.visitInlines(next.Tail(), env); text != nil { + return sz.MakeHeading(int(level), attrs, text, slug.GetValue(), fragment.GetValue()) } return nil } func postProcessItemList(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { @@ -182,13 +182,14 @@ var newPara sx.ListBuilder addtoParagraph := func() { if !newPara.IsEmpty() { newElems.Add(sx.MakeList(sz.SymBlock, newPara.List().Cons(sz.SymPara))) + newPara.Reset() } } - for node := elems; node != nil; node = node.Tail() { + for node := range elems.Pairs() { item := node.Head() if !item.Car().IsEqual(sz.SymBlock) { continue } itemTail := item.Tail() @@ -211,11 +212,11 @@ return newElems.List().Cons(ln.Car()) } func (pp *postProcessor) visitListElems(ln *sx.Pair, env *sx.Pair) *sx.Pair { var pList sx.ListBuilder - for node := ln.Tail(); node != nil; node = node.Tail() { + for node := range ln.Tail().Pairs() { if elem := sz.Walk(pp, node.Head(), env); elem != nil { pList.Add(elem) } } return pList.List() @@ -222,11 +223,11 @@ } func postProcessDescription(pp *postProcessor, dl *sx.Pair, env *sx.Pair) *sx.Pair { var dList sx.ListBuilder isTerm := false - for node := dl.Tail(); node != nil; node = node.Tail() { + for node := range dl.Tail().Pairs() { isTerm = !isTerm if isTerm { dList.Add(pp.visitInlines(node.Head(), env)) } else { dList.Add(sz.Walk(pp, node.Head(), env)) @@ -248,20 +249,20 @@ // Header and row are nil -> no table return nil } header, rows, align := splitTableHeader(rows, width) alignRow(header, align) - for node := rows; node != nil; node = node.Tail() { + for node := range rows.Pairs() { alignRow(node.Head(), align) } return rows.Cons(header).Cons(sym) } func (pp *postProcessor) visitRows(rows *sx.Pair, env *sx.Pair) (*sx.Pair, int) { maxWidth := 0 var pRows sx.ListBuilder - for node := rows; node != nil; node = node.Tail() { + for node := range rows.Pairs() { row := node.Head() row, width := pp.visitCells(row, env) if maxWidth < width { maxWidth = width } @@ -271,11 +272,11 @@ } func (pp *postProcessor) visitCells(cells *sx.Pair, env *sx.Pair) (*sx.Pair, int) { width := 0 var pCells sx.ListBuilder - for node := cells; node != nil; node = node.Tail() { + for node := range cells.Pairs() { cell := node.Head() ins := pp.visitInlines(cell.Tail(), env) newCell := ins.Cons(cell.Car()) pCells.Add(newCell) width++ @@ -288,13 +289,13 @@ foundHeader := false cellCount := 0 // assert: rows != nil (checked in postProcessTable) - for node := rows.Head(); node != nil; node = node.Tail() { - cellCount++ + for node := range rows.Head().Pairs() { cell := node.Head() + cellCount++ cellTail := cell.Tail() if cellTail == nil { continue } @@ -332,17 +333,17 @@ } } } if !foundHeader { - for i := 0; i < width; i++ { + for i := range width { align[i] = sz.SymCell // Default alignment } return nil, rows, align } - for i := 0; i < width; i++ { + for i := range width { if align[i] == nil { align[i] = sz.SymCell // Default alignment } } return rows.Head(), rows.Tail(), align @@ -352,11 +353,11 @@ if row == nil { return } var lastCellNode *sx.Pair cellCount := 0 - for node := row; node != nil; node = node.Tail() { + for node := range row.Pairs() { lastCellNode = node cell := node.Head() cell.SetCar(align[cellCount]) cellCount++ cellTail := cell.Tail() @@ -403,24 +404,33 @@ return nil } inVerse := env.Assoc(symInVerse) != nil vector := make([]*sx.Pair, 0, length) // 1st phase: process all childs, ignore ' ' / '\t' at start, and merge some elements - for node := lst; node != nil; node = node.Tail() { - elem := sz.Walk(pp, node.Head(), env) - if elem == nil { + for node := range lst.Pairs() { + elem, isPair := sx.GetPair(sz.Walk(pp, node.Head(), env)) + if !isPair || elem == nil { continue } elemSym := elem.Car() + elemTail := elem.Tail() + + if inVerse && elemSym.IsEqual(sz.SymText) { + if s, isString := sx.GetString(elemTail.Car()); isString { + verseText := s.GetValue() + verseText = strings.ReplaceAll(verseText, " ", "\u00a0") + elemTail.SetCar(sx.MakeString(verseText)) + } + } + if len(vector) == 0 { // If the 1st element is a TEXT, remove all ' ', '\t' at the beginning, if outside a verse block. - if inVerse || !elemSym.IsEqual(sz.SymText) { + if !elemSym.IsEqual(sz.SymText) { vector = append(vector, elem) continue } - elemTail := elem.Tail() elemText := elemTail.Car().(sx.String).GetValue() if elemText != "" && (elemText[0] == ' ' || elemText[0] == '\t') { for elemText != "" { if ch := elemText[0]; ch != ' ' && ch != '\t' { break @@ -523,29 +533,27 @@ } return sx.Cons(sz.SymHard, nil) } func postProcessEndnote(pp *postProcessor, en *sx.Pair, env *sx.Pair) *sx.Pair { - sym := en.Car() next := en.Tail() - attrs := next.Car() + attrs := next.Car().(*sx.Pair) if text := pp.visitInlines(next.Tail(), env); text != nil { - return text.Cons(attrs).Cons(sym) + return sz.MakeEndnote(attrs, text) } - return sx.MakeList(sym, attrs) + return sz.MakeEndnote(attrs, sx.Nil()) } func postProcessMark(pp *postProcessor, en *sx.Pair, env *sx.Pair) *sx.Pair { - sym := en.Car() - next := en.Tail() - mark := next.Car() - next = next.Tail() - slug := next.Car() - next = next.Tail() - fragment := next.Car() - text := pp.visitInlines(next.Tail(), env) - return text.Cons(fragment).Cons(slug).Cons(mark).Cons(sym) + next := en.Tail() + mark := next.Car().(sx.String) + next = next.Tail() + slug := next.Car().(sx.String) + next = next.Tail() + fragment := next.Car().(sx.String) + text := pp.visitInlines(next.Tail(), env) + return sz.MakeMark(mark.GetValue(), slug.GetValue(), fragment.GetValue(), text) } func postProcessInlines4(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { sym := ln.Car() next := ln.Tail() @@ -553,17 +561,28 @@ next = next.Tail() val3 := next.Car() text := pp.visitInlines(next.Tail(), env) return text.Cons(val3).Cons(attrs).Cons(sym) } + +func postProcessEmbed(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { + next := ln.Tail() + attrs := next.Car().(*sx.Pair) + next = next.Tail() + ref := next.Car() + next = next.Tail() + syntax := next.Car().(sx.String) + text := pp.visitInlines(next.Tail(), env) + return sz.MakeEmbed(attrs, ref, syntax.GetValue(), text) +} func postProcessFormat(pp *postProcessor, fn *sx.Pair, env *sx.Pair) *sx.Pair { - symFormat := fn.Car() + symFormat := fn.Car().(*sx.Symbol) next := fn.Tail() // Attrs - attrs := next.Car() + attrs := next.Car().(*sx.Pair) next = next.Tail() // Possible inlines if next == nil { return fn } inlines := pp.visitInlines(next, env) - return inlines.Cons(attrs).Cons(symFormat) + return sz.MakeFormat(symFormat, attrs, inlines) } Index: sz/zmk/ref.go ================================================================== --- sz/zmk/ref.go +++ sz/zmk/ref.go @@ -17,10 +17,11 @@ "net/url" "strings" "t73f.de/r/sx" "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/id" "t73f.de/r/zsc/sz" ) // ParseReference parses a string and returns a reference. func ParseReference(s string) *sx.Pair { @@ -42,12 +43,11 @@ u, err := url.Parse(s) if err != nil { return makePairRef(sz.SymRefStateInvalid, s) } if !externalURL(u) { - zid := api.ZettelID(u.Path) - if zid.IsValid() { + if _, err = id.Parse(u.Path); err == nil { return makePairRef(sz.SymRefStateZettel, s) } if u.Path == "" && u.Fragment != "" { return makePairRef(sz.SymRefStateSelf, s) } Index: sz/zmk/zmk.go ================================================================== --- sz/zmk/zmk.go +++ sz/zmk/zmk.go @@ -13,21 +13,22 @@ // Package zmk provides a parser for zettelmarkup. package zmk import ( + "maps" "slices" "strings" "unicode" "t73f.de/r/sx" "t73f.de/r/zsc/input" "t73f.de/r/zsc/sz" ) -// ParseBlocks tries to parse the input as a block element. -func ParseBlocks(inp *input.Input) *sx.Pair { +// Parse tries to parse the input as a block element. +func Parse(inp *input.Input) *sx.Pair { parser := zmkP{inp: inp} var lastPara *sx.Pair var blkBuild sx.ListBuilder for inp.Ch != input.EOS { @@ -53,35 +54,16 @@ return bs.Cons(sz.SymBlock) } return nil } -// ParseInlines tries to parse the input as an inline element. -func ParseInlines(inp *input.Input) *sx.Pair { - parser := zmkP{inp: inp} - var ins sx.Vector - for inp.Ch != input.EOS { - in := parser.parseInline() - if in == nil { - break - } - ins = append(ins, in) - } - - inl := ins.MakeList().Cons(sz.SymInline) - var pp postProcessor - return sz.Walk(&pp, inl, nil) -} - type zmkP struct { inp *input.Input // Input stream lists []*sx.Pair // Stack of lists lastRow *sx.Pair // Last row of table, or nil if not in table. descrl *sx.Pair // Current description list nestingLevel int // Count nesting of block and inline elements - - inVerse bool // Currently in a vers region? } // runeModGrave is Unicode code point U+02CB (715) called "MODIFIER LETTER // GRAVE ACCENT". On the iPad it is much more easier to type in this code point // than U+0060 (96) "Grave accent" (aka backtick). Therefore, U+02CB will be @@ -106,25 +88,18 @@ attrs[key] = val } } func (attrs attrMap) asPairAssoc() *sx.Pair { - names := make([]string, 0, len(attrs)) - for n := range attrs { - names = append(names, n) - } - slices.Sort(names) - var assoc *sx.Pair - for i := len(names) - 1; i >= 0; i-- { - n := names[i] - assoc = assoc.Cons(sx.Cons(sx.MakeString(n), sx.MakeString(attrs[n]))) - } - return assoc -} - -func (cp *zmkP) parseNormalAttribute(attrs attrMap) bool { - inp := cp.inp + var lb sx.ListBuilder + for _, key := range slices.Sorted(maps.Keys(attrs)) { + lb.Add(sx.Cons(sx.MakeString(key), sx.MakeString(attrs[key]))) + } + return lb.List() +} + +func parseNormalAttribute(inp *input.Input, attrs attrMap) bool { posK := inp.Pos for isNameRune(inp.Ch) { inp.Next() } if posK == inp.Pos { @@ -133,17 +108,16 @@ key := string(inp.Src[posK:inp.Pos]) if inp.Ch != '=' { attrs[key] = "" return true } - return cp.parseAttributeValue(key, attrs) + return parseAttributeValue(inp, key, attrs) } -func (cp *zmkP) parseAttributeValue(key string, attrs attrMap) bool { - inp := cp.inp +func parseAttributeValue(inp *input.Input, key string, attrs attrMap) bool { if inp.Next() == '"' { - return cp.parseQuotedAttributeValue(key, attrs) + return parseQuotedAttributeValue(inp, key, attrs) } posV := inp.Pos for { switch inp.Ch { case input.EOS: @@ -154,12 +128,11 @@ } inp.Next() } } -func (cp *zmkP) parseQuotedAttributeValue(key string, attrs attrMap) bool { - inp := cp.inp +func parseQuotedAttributeValue(inp *input.Input, key string, attrs attrMap) bool { inp.Next() var sb strings.Builder for { switch inp.Ch { case input.EOS: @@ -180,12 +153,11 @@ } } } -func (cp *zmkP) parseBlockAttributes() *sx.Pair { - inp := cp.inp +func parseBlockAttributes(inp *input.Input) *sx.Pair { pos := inp.Pos for isNameRune(inp.Ch) { inp.Next() } if pos < inp.Pos { @@ -192,42 +164,39 @@ return attrMap{"": string(inp.Src[pos:inp.Pos])}.asPairAssoc() } // No immediate name: skip spaces inp.SkipSpace() - return cp.parseInlineAttributes() + return parseInlineAttributes(inp) } -func (cp *zmkP) parseInlineAttributes() *sx.Pair { - inp := cp.inp +func parseInlineAttributes(inp *input.Input) *sx.Pair { pos := inp.Pos - if attrs, success := cp.doParseAttributes(); success { + if attrs, success := doParseAttributes(inp); success { return attrs } inp.SetPos(pos) return nil } // doParseAttributes reads attributes. -func (cp *zmkP) doParseAttributes() (res *sx.Pair, success bool) { - inp := cp.inp +func doParseAttributes(inp *input.Input) (*sx.Pair, bool) { if inp.Ch != '{' { return nil, false } inp.Next() a := attrMap{} - if !cp.parseAttributeValues(a) { + if !parseAttributeValues(inp, a) { return nil, false } inp.Next() return a.asPairAssoc(), true } -func (cp *zmkP) parseAttributeValues(a attrMap) bool { - inp := cp.inp +func parseAttributeValues(inp *input.Input, a attrMap) bool { for { - cp.skipSpaceLine() + skipSpaceLine(inp) switch inp.Ch { case input.EOS: return false case '}': return true @@ -241,15 +210,15 @@ return false } a.updateAttrs("class", string(inp.Src[posC:inp.Pos])) case '=': delete(a, "") - if !cp.parseAttributeValue("", a) { + if !parseAttributeValue(inp, "", a) { return false } default: - if !cp.parseNormalAttribute(a) { + if !parseNormalAttribute(inp, a) { return false } } switch inp.Ch { @@ -262,12 +231,12 @@ return false } } } -func (cp *zmkP) skipSpaceLine() { - for inp := cp.inp; ; { +func skipSpaceLine(inp *input.Input) { + for { switch inp.Ch { case ' ': inp.Next() case '\n', '\r': inp.EatEOL() Index: sz/zmk/zmk_fuzz_test.go ================================================================== --- sz/zmk/zmk_fuzz_test.go +++ sz/zmk/zmk_fuzz_test.go @@ -22,8 +22,8 @@ func FuzzParseBlocks(f *testing.F) { f.Fuzz(func(t *testing.T, src []byte) { t.Parallel() inp := input.NewInput(src) - zmk.ParseBlocks(inp) + zmk.Parse(inp) }) } Index: sz/zmk/zmk_test.go ================================================================== --- sz/zmk/zmk_test.go +++ sz/zmk/zmk_test.go @@ -45,282 +45,271 @@ testCases = append(testCases, TestCase{source, want}) } return testCases } -func checkTcs(t *testing.T, isBlock bool, tcs TestCases) { +func checkTcs(t *testing.T, tcs TestCases) { t.Helper() for tcn, tc := range tcs { t.Run(fmt.Sprintf("TC=%02d,src=%q", tcn, tc.source), func(st *testing.T) { st.Helper() - ast := parseInput(tc.source, isBlock) + inp := input.NewInput([]byte(tc.source)) + ast := zmk.Parse(inp) sz.Walk(astWalker{}, ast, nil) got := ast.String() if tc.want != got { st.Errorf("\nwant=%q\n got=%q", tc.want, got) } }) } } -func parseInput(src string, asBlock bool) *sx.Pair { - inp := input.NewInput([]byte(src)) - if asBlock { - bl := zmk.ParseBlocks(inp) - return bl - } - il := zmk.ParseInlines(inp) - return il -} type astWalker struct{} -func (astWalker) Visit(node *sx.Pair, env *sx.Pair) sx.Object { return sx.MakeBoolean(true) } +func (astWalker) VisitBefore(node *sx.Pair, env *sx.Pair) (sx.Object, bool) { return sx.Nil(), false } +func (astWalker) VisitAfter(node *sx.Pair, env *sx.Pair) sx.Object { return node } func TestEOL(t *testing.T) { t.Parallel() - for _, isBlock := range []bool{true, false} { - checkTcs(t, isBlock, TestCases{ - {"", "()"}, - {"\n", "()"}, - {"\r", "()"}, - {"\r\n", "()"}, - {"\n\n", "()"}, - }) - } + checkTcs(t, TestCases{ + {"", "()"}, + {"\n", "()"}, + {"\r", "()"}, + {"\r\n", "()"}, + {"\n\n", "()"}, + }) } func TestText(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"abcd", "(INLINE (TEXT \"abcd\"))"}, - {"ab cd", "(INLINE (TEXT \"ab cd\"))"}, - {"abcd ", "(INLINE (TEXT \"abcd\"))"}, - {" abcd", "(INLINE (TEXT \"abcd\"))"}, - {"\\", "(INLINE (TEXT \"\\\\\"))"}, + checkTcs(t, TestCases{ + {"abcd", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {"ab cd", "(BLOCK (PARA (TEXT \"ab cd\")))"}, + {"abcd ", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {" abcd", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {"\\", "(BLOCK (PARA (TEXT \"\\\\\")))"}, {"\\\n", "()"}, - {"\\\ndef", "(INLINE (HARD) (TEXT \"def\"))"}, + {"\\\ndef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, {"\\\r", "()"}, - {"\\\rdef", "(INLINE (HARD) (TEXT \"def\"))"}, + {"\\\rdef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, {"\\\r\n", "()"}, - {"\\\r\ndef", "(INLINE (HARD) (TEXT \"def\"))"}, - {"\\a", "(INLINE (TEXT \"a\"))"}, - {"\\aa", "(INLINE (TEXT \"aa\"))"}, - {"a\\a", "(INLINE (TEXT \"aa\"))"}, - {"\\+", "(INLINE (TEXT \"+\"))"}, - {"\\ ", "(INLINE (TEXT \"\u00a0\"))"}, - {"http://a, http://b", "(INLINE (TEXT \"http://a, http://b\"))"}, + {"\\\r\ndef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, + {"\\a", "(BLOCK (PARA (TEXT \"a\")))"}, + {"\\aa", "(BLOCK (PARA (TEXT \"aa\")))"}, + {"a\\a", "(BLOCK (PARA (TEXT \"aa\")))"}, + {"\\+", "(BLOCK (PARA (TEXT \"+\")))"}, + {"\\ ", "(BLOCK (PARA (TEXT \"\u00a0\")))"}, + {"http://a, http://b", "(BLOCK (PARA (TEXT \"http://a, http://b\")))"}, }) } func TestSpace(t *testing.T) { t.Parallel() - for _, isBlock := range []bool{true, false} { - checkTcs(t, isBlock, TestCases{ - {" ", "()"}, - {"\t", "()"}, - {" ", "()"}, - }) - } + checkTcs(t, TestCases{ + {" ", "()"}, + {"\t", "()"}, + {" ", "()"}, + }) } func TestSoftBreak(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"x\ny", "(INLINE (TEXT \"x\") (SOFT) (TEXT \"y\"))"}, - {"z\n", "(INLINE (TEXT \"z\"))"}, + checkTcs(t, TestCases{ + {"x\ny", "(BLOCK (PARA (TEXT \"x\") (SOFT) (TEXT \"y\")))"}, + {"z\n", "(BLOCK (PARA (TEXT \"z\")))"}, {" \n ", "()"}, {" \n", "()"}, }) } func TestHardBreak(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"x \ny", "(INLINE (TEXT \"x\") (HARD) (TEXT \"y\"))"}, - {"z \n", "(INLINE (TEXT \"z\"))"}, + checkTcs(t, TestCases{ + {"x \ny", "(BLOCK (PARA (TEXT \"x\") (HARD) (TEXT \"y\")))"}, + {"z \n", "(BLOCK (PARA (TEXT \"z\")))"}, {" \n ", "()"}, {" \n", "()"}, }) } func TestLink(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"[", "(INLINE (TEXT \"[\"))"}, - {"[[", "(INLINE (TEXT \"[[\"))"}, - {"[[|", "(INLINE (TEXT \"[[|\"))"}, - {"[[]", "(INLINE (TEXT \"[[]\"))"}, - {"[[|]", "(INLINE (TEXT \"[[|]\"))"}, - {"[[]]", "(INLINE (TEXT \"[[]]\"))"}, - {"[[|]]", "(INLINE (TEXT \"[[|]]\"))"}, - {"[[ ]]", "(INLINE (TEXT \"[[ ]]\"))"}, - {"[[\n]]", "(INLINE (TEXT \"[[\") (SOFT) (TEXT \"]]\"))"}, - {"[[ a]]", "(INLINE (LINK-EXTERNAL () \"a\"))"}, - {"[[a ]]", "(INLINE (TEXT \"[[a ]]\"))"}, - {"[[a\n]]", "(INLINE (TEXT \"[[a\") (SOFT) (TEXT \"]]\"))"}, - {"[[a]]", "(INLINE (LINK-EXTERNAL () \"a\"))"}, - {"[[12345678901234]]", "(INLINE (LINK-ZETTEL () \"12345678901234\"))"}, - {"[[a]", "(INLINE (TEXT \"[[a]\"))"}, - {"[[|a]]", "(INLINE (TEXT \"[[|a]]\"))"}, - {"[[b|]]", "(INLINE (TEXT \"[[b|]]\"))"}, - {"[[b|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b\")))"}, - {"[[b| a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b\")))"}, - {"[[b%c|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b%c\")))"}, - {"[[b%%c|a]]", "(INLINE (TEXT \"[[b\") (LITERAL-COMMENT () \"c|a]]\"))"}, - {"[[b|a]", "(INLINE (TEXT \"[[b|a]\"))"}, - {"[[b\nc|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b\") (SOFT) (TEXT \"c\")))"}, - {"[[b c|a#n]]", "(INLINE (LINK-EXTERNAL () \"a#n\" (TEXT \"b c\")))"}, - {"[[a]]go", "(INLINE (LINK-EXTERNAL () \"a\") (TEXT \"go\"))"}, - {"[[b|a]]{go}", "(INLINE (LINK-EXTERNAL ((\"go\" . \"\")) \"a\" (TEXT \"b\")))"}, - {"[[[[a]]|b]]", "(INLINE (TEXT \"[[\") (LINK-EXTERNAL () \"a\") (TEXT \"|b]]\"))"}, - {"[[a[b]c|d]]", "(INLINE (LINK-EXTERNAL () \"d\" (TEXT \"a[b]c\")))"}, - {"[[[b]c|d]]", "(INLINE (TEXT \"[\") (LINK-EXTERNAL () \"d\" (TEXT \"b]c\")))"}, - {"[[a[]c|d]]", "(INLINE (LINK-EXTERNAL () \"d\" (TEXT \"a[]c\")))"}, - {"[[a[b]|d]]", "(INLINE (LINK-EXTERNAL () \"d\" (TEXT \"a[b]\")))"}, - {"[[\\|]]", "(INLINE (LINK-EXTERNAL () \"\\\\|\"))"}, - {"[[\\||a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"|\")))"}, - {"[[b\\||a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b|\")))"}, - {"[[b\\|c|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b|c\")))"}, - {"[[\\]]]", "(INLINE (LINK-EXTERNAL () \"\\\\]\"))"}, - {"[[\\]|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"]\")))"}, - {"[[b\\]|a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"b]\")))"}, - {"[[\\]\\||a]]", "(INLINE (LINK-EXTERNAL () \"a\" (TEXT \"]|\")))"}, - {"[[http://a]]", "(INLINE (LINK-EXTERNAL () \"http://a\"))"}, - {"[[http://a|http://a]]", "(INLINE (LINK-EXTERNAL () \"http://a\" (TEXT \"http://a\")))"}, - {"[[[[a]]]]", "(INLINE (TEXT \"[[\") (LINK-EXTERNAL () \"a\") (TEXT \"]]\"))"}, - {"[[query:title]]", "(INLINE (LINK-QUERY () \"title\"))"}, - {"[[query:title syntax]]", "(INLINE (LINK-QUERY () \"title syntax\"))"}, - {"[[query:title | action]]", "(INLINE (LINK-QUERY () \"title | action\"))"}, - {"[[Text|query:title]]", "(INLINE (LINK-QUERY () \"title\" (TEXT \"Text\")))"}, - {"[[Text|query:title syntax]]", "(INLINE (LINK-QUERY () \"title syntax\" (TEXT \"Text\")))"}, - {"[[Text|query:title | action]]", "(INLINE (LINK-QUERY () \"title | action\" (TEXT \"Text\")))"}, + checkTcs(t, TestCases{ + {"[", "(BLOCK (PARA (TEXT \"[\")))"}, + {"[[", "(BLOCK (PARA (TEXT \"[[\")))"}, + {"[[|", "(BLOCK (PARA (TEXT \"[[|\")))"}, + {"[[]", "(BLOCK (PARA (TEXT \"[[]\")))"}, + {"[[|]", "(BLOCK (PARA (TEXT \"[[|]\")))"}, + {"[[]]", "(BLOCK (PARA (TEXT \"[[]]\")))"}, + {"[[|]]", "(BLOCK (PARA (TEXT \"[[|]]\")))"}, + {"[[ ]]", "(BLOCK (PARA (TEXT \"[[ ]]\")))"}, + {"[[\n]]", "(BLOCK (PARA (TEXT \"[[\") (SOFT) (TEXT \"]]\")))"}, + {"[[ a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\")))"}, + {"[[a ]]", "(BLOCK (PARA (TEXT \"[[a ]]\")))"}, + {"[[a\n]]", "(BLOCK (PARA (TEXT \"[[a\") (SOFT) (TEXT \"]]\")))"}, + {"[[a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\")))"}, + {"[[12345678901234]]", "(BLOCK (PARA (LINK-ZETTEL () \"12345678901234\")))"}, + {"[[a]", "(BLOCK (PARA (TEXT \"[[a]\")))"}, + {"[[|a]]", "(BLOCK (PARA (TEXT \"[[|a]]\")))"}, + {"[[b|]]", "(BLOCK (PARA (TEXT \"[[b|]]\")))"}, + {"[[b|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b\"))))"}, + {"[[b| a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b\"))))"}, + {"[[b%c|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b%c\"))))"}, + {"[[b%%c|a]]", "(BLOCK (PARA (TEXT \"[[b\") (LITERAL-COMMENT () \"c|a]]\")))"}, + {"[[b|a]", "(BLOCK (PARA (TEXT \"[[b|a]\")))"}, + {"[[b\nc|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b\") (SOFT) (TEXT \"c\"))))"}, + {"[[b c|a#n]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a#n\" (TEXT \"b c\"))))"}, + {"[[a]]go", "(BLOCK (PARA (LINK-EXTERNAL () \"a\") (TEXT \"go\")))"}, + {"[[b|a]]{go}", "(BLOCK (PARA (LINK-EXTERNAL ((\"go\" . \"\")) \"a\" (TEXT \"b\"))))"}, + {"[[[[a]]|b]]", "(BLOCK (PARA (TEXT \"[[\") (LINK-EXTERNAL () \"a\") (TEXT \"|b]]\")))"}, + {"[[a[b]c|d]]", "(BLOCK (PARA (LINK-EXTERNAL () \"d\" (TEXT \"a[b]c\"))))"}, + {"[[[b]c|d]]", "(BLOCK (PARA (TEXT \"[\") (LINK-EXTERNAL () \"d\" (TEXT \"b]c\"))))"}, + {"[[a[]c|d]]", "(BLOCK (PARA (LINK-EXTERNAL () \"d\" (TEXT \"a[]c\"))))"}, + {"[[a[b]|d]]", "(BLOCK (PARA (LINK-EXTERNAL () \"d\" (TEXT \"a[b]\"))))"}, + {"[[\\|]]", "(BLOCK (PARA (LINK-EXTERNAL () \"\\\\|\")))"}, + {"[[\\||a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"|\"))))"}, + {"[[b\\||a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b|\"))))"}, + {"[[b\\|c|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b|c\"))))"}, + {"[[\\]]]", "(BLOCK (PARA (LINK-EXTERNAL () \"\\\\]\")))"}, + {"[[\\]|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"]\"))))"}, + {"[[b\\]|a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"b]\"))))"}, + {"[[\\]\\||a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"a\" (TEXT \"]|\"))))"}, + {"[[http://a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"http://a\")))"}, + {"[[http://a|http://a]]", "(BLOCK (PARA (LINK-EXTERNAL () \"http://a\" (TEXT \"http://a\"))))"}, + {"[[[[a]]]]", "(BLOCK (PARA (TEXT \"[[\") (LINK-EXTERNAL () \"a\") (TEXT \"]]\")))"}, + {"[[query:title]]", "(BLOCK (PARA (LINK-QUERY () \"title\")))"}, + {"[[query:title syntax]]", "(BLOCK (PARA (LINK-QUERY () \"title syntax\")))"}, + {"[[query:title | action]]", "(BLOCK (PARA (LINK-QUERY () \"title | action\")))"}, + {"[[Text|query:title]]", "(BLOCK (PARA (LINK-QUERY () \"title\" (TEXT \"Text\"))))"}, + {"[[Text|query:title syntax]]", "(BLOCK (PARA (LINK-QUERY () \"title syntax\" (TEXT \"Text\"))))"}, + {"[[Text|query:title | action]]", "(BLOCK (PARA (LINK-QUERY () \"title | action\" (TEXT \"Text\"))))"}, }) } func TestEmbed(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"{", "(INLINE (TEXT \"{\"))"}, - {"{{", "(INLINE (TEXT \"{{\"))"}, - {"{{|", "(INLINE (TEXT \"{{|\"))"}, - {"{{}", "(INLINE (TEXT \"{{}\"))"}, - {"{{|}", "(INLINE (TEXT \"{{|}\"))"}, - {"{{}}", "(INLINE (TEXT \"{{}}\"))"}, - {"{{|}}", "(INLINE (TEXT \"{{|}}\"))"}, - {"{{ }}", "(INLINE (TEXT \"{{ }}\"))"}, - {"{{\n}}", "(INLINE (TEXT \"{{\") (SOFT) (TEXT \"}}\"))"}, - {"{{a }}", "(INLINE (TEXT \"{{a }}\"))"}, - {"{{a\n}}", "(INLINE (TEXT \"{{a\") (SOFT) (TEXT \"}}\"))"}, - {"{{a}}", "(INLINE (EMBED () \"a\"))"}, - {"{{12345678901234}}", "(INLINE (EMBED () \"12345678901234\"))"}, - {"{{ a}}", "(INLINE (EMBED () \"a\"))"}, - {"{{a}", "(INLINE (TEXT \"{{a}\"))"}, - {"{{|a}}", "(INLINE (TEXT \"{{|a}}\"))"}, - {"{{b|}}", "(INLINE (TEXT \"{{b|}}\"))"}, - {"{{b|a}}", "(INLINE (EMBED () \"a\" (TEXT \"b\")))"}, - {"{{b| a}}", "(INLINE (EMBED () \"a\" (TEXT \"b\")))"}, - {"{{b|a}", "(INLINE (TEXT \"{{b|a}\"))"}, - {"{{b\nc|a}}", "(INLINE (EMBED () \"a\" (TEXT \"b\") (SOFT) (TEXT \"c\")))"}, - {"{{b c|a#n}}", "(INLINE (EMBED () \"a#n\" (TEXT \"b c\")))"}, - {"{{a}}{go}", "(INLINE (EMBED ((\"go\" . \"\")) \"a\"))"}, - {"{{{{a}}|b}}", "(INLINE (TEXT \"{{\") (EMBED () \"a\") (TEXT \"|b}}\"))"}, - {"{{\\|}}", "(INLINE (EMBED () \"\\\\|\"))"}, - {"{{\\||a}}", "(INLINE (EMBED () \"a\" (TEXT \"|\")))"}, - {"{{b\\||a}}", "(INLINE (EMBED () \"a\" (TEXT \"b|\")))"}, - {"{{b\\|c|a}}", "(INLINE (EMBED () \"a\" (TEXT \"b|c\")))"}, - {"{{\\}}}", "(INLINE (EMBED () \"\\\\}\"))"}, - {"{{\\}|a}}", "(INLINE (EMBED () \"a\" (TEXT \"}\")))"}, - {"{{b\\}|a}}", "(INLINE (EMBED () \"a\" (TEXT \"b}\")))"}, - {"{{\\}\\||a}}", "(INLINE (EMBED () \"a\" (TEXT \"}|\")))"}, - {"{{http://a}}", "(INLINE (EMBED () \"http://a\"))"}, - {"{{http://a|http://a}}", "(INLINE (EMBED () \"http://a\" (TEXT \"http://a\")))"}, - {"{{{{a}}}}", "(INLINE (TEXT \"{{\") (EMBED () \"a\") (TEXT \"}}\"))"}, + checkTcs(t, TestCases{ + {"{", "(BLOCK (PARA (TEXT \"{\")))"}, + {"{{", "(BLOCK (PARA (TEXT \"{{\")))"}, + {"{{|", "(BLOCK (PARA (TEXT \"{{|\")))"}, + {"{{}", "(BLOCK (PARA (TEXT \"{{}\")))"}, + {"{{|}", "(BLOCK (PARA (TEXT \"{{|}\")))"}, + {"{{}}", "(BLOCK (PARA (TEXT \"{{}}\")))"}, + {"{{|}}", "(BLOCK (PARA (TEXT \"{{|}}\")))"}, + {"{{ }}", "(BLOCK (PARA (TEXT \"{{ }}\")))"}, + {"{{\n}}", "(BLOCK (PARA (TEXT \"{{\") (SOFT) (TEXT \"}}\")))"}, + {"{{a }}", "(BLOCK (PARA (TEXT \"{{a }}\")))"}, + {"{{a\n}}", "(BLOCK (PARA (TEXT \"{{a\") (SOFT) (TEXT \"}}\")))"}, + {"{{a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\")))"}, + {"{{12345678901234}}", "(BLOCK (PARA (EMBED () (ZETTEL \"12345678901234\") \"\")))"}, + {"{{ a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\")))"}, + {"{{a}", "(BLOCK (PARA (TEXT \"{{a}\")))"}, + {"{{|a}}", "(BLOCK (PARA (TEXT \"{{|a}}\")))"}, + {"{{b|}}", "(BLOCK (PARA (TEXT \"{{b|}}\")))"}, + {"{{b|a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b\"))))"}, + {"{{b| a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b\"))))"}, + {"{{b|a}", "(BLOCK (PARA (TEXT \"{{b|a}\")))"}, + {"{{b\nc|a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b\") (SOFT) (TEXT \"c\"))))"}, + {"{{b c|a#n}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a#n\") \"\" (TEXT \"b c\"))))"}, + {"{{a}}{go}", "(BLOCK (PARA (EMBED ((\"go\" . \"\")) (EXTERNAL \"a\") \"\")))"}, + {"{{{{a}}|b}}", "(BLOCK (PARA (TEXT \"{{\") (EMBED () (EXTERNAL \"a\") \"\") (TEXT \"|b}}\")))"}, + {"{{\\|}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"\\\\|\") \"\")))"}, + {"{{\\||a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"|\"))))"}, + {"{{b\\||a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b|\"))))"}, + {"{{b\\|c|a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b|c\"))))"}, + {"{{\\}}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"\\\\}\") \"\")))"}, + {"{{\\}|a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"}\"))))"}, + {"{{b\\}|a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"b}\"))))"}, + {"{{\\}\\||a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"a\") \"\" (TEXT \"}|\"))))"}, + {"{{http://a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"http://a\") \"\")))"}, + {"{{http://a|http://a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"http://a\") \"\" (TEXT \"http://a\"))))"}, + {"{{{{a}}}}", "(BLOCK (PARA (TEXT \"{{\") (EMBED () (EXTERNAL \"a\") \"\") (TEXT \"}}\")))"}, }) } func TestCite(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"[@", "(INLINE (TEXT \"[@\"))"}, - {"[@]", "(INLINE (TEXT \"[@]\"))"}, - {"[@a]", "(INLINE (CITE () \"a\"))"}, - {"[@ a]", "(INLINE (TEXT \"[@ a]\"))"}, - {"[@a ]", "(INLINE (CITE () \"a\"))"}, - {"[@a\n]", "(INLINE (CITE () \"a\"))"}, - {"[@a\nx]", "(INLINE (CITE () \"a\" (SOFT) (TEXT \"x\")))"}, - {"[@a\n\n]", "(INLINE (TEXT \"[@a\") (SOFT) (SOFT) (TEXT \"]\"))"}, - {"[@a,\n]", "(INLINE (CITE () \"a\"))"}, - {"[@a,n]", "(INLINE (CITE () \"a\" (TEXT \"n\")))"}, - {"[@a| n]", "(INLINE (CITE () \"a\" (TEXT \"n\")))"}, - {"[@a|n ]", "(INLINE (CITE () \"a\" (TEXT \"n\")))"}, - {"[@a,[@b]]", "(INLINE (CITE () \"a\" (CITE () \"b\")))"}, - {"[@a]{color=green}", "(INLINE (CITE ((\"color\" . \"green\")) \"a\"))"}, + checkTcs(t, TestCases{ + {"[@", "(BLOCK (PARA (TEXT \"[@\")))"}, + {"[@]", "(BLOCK (PARA (TEXT \"[@]\")))"}, + {"[@a]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@ a]", "(BLOCK (PARA (TEXT \"[@ a]\")))"}, + {"[@a ]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a\n]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a\nx]", "(BLOCK (PARA (CITE () \"a\" (SOFT) (TEXT \"x\"))))"}, + {"[@a\n\n]", "(BLOCK (PARA (TEXT \"[@a\")) (PARA (TEXT \"]\")))"}, + {"[@a,\n]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a,n]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a| n]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a|n ]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a,[@b]]", "(BLOCK (PARA (CITE () \"a\" (CITE () \"b\"))))"}, + {"[@a]{color=green}", "(BLOCK (PARA (CITE ((\"color\" . \"green\")) \"a\")))"}, }) - checkTcs(t, true, TestCases{ + checkTcs(t, TestCases{ {"[@a\n\n]", "(BLOCK (PARA (TEXT \"[@a\")) (PARA (TEXT \"]\")))"}, }) } func TestEndnote(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"[^", "(INLINE (TEXT \"[^\"))"}, - {"[^]", "(INLINE (ENDNOTE ()))"}, - {"[^abc]", "(INLINE (ENDNOTE () (TEXT \"abc\")))"}, - {"[^abc ]", "(INLINE (ENDNOTE () (TEXT \"abc\")))"}, - {"[^abc\ndef]", "(INLINE (ENDNOTE () (TEXT \"abc\") (SOFT) (TEXT \"def\")))"}, - {"[^abc\n\ndef]", "(INLINE (TEXT \"[^abc\") (SOFT) (SOFT) (TEXT \"def]\"))"}, - {"[^abc[^def]]", "(INLINE (ENDNOTE () (TEXT \"abc\") (ENDNOTE () (TEXT \"def\"))))"}, - {"[^abc]{-}", "(INLINE (ENDNOTE ((\"-\" . \"\")) (TEXT \"abc\")))"}, + checkTcs(t, TestCases{ + {"[^", "(BLOCK (PARA (TEXT \"[^\")))"}, + {"[^]", "(BLOCK (PARA (ENDNOTE ())))"}, + {"[^abc]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\"))))"}, + {"[^abc ]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\"))))"}, + {"[^abc\ndef]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\") (SOFT) (TEXT \"def\"))))"}, + {"[^abc\n\ndef]", "(BLOCK (PARA (TEXT \"[^abc\")) (PARA (TEXT \"def]\")))"}, + {"[^abc[^def]]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\") (ENDNOTE () (TEXT \"def\")))))"}, + {"[^abc]{-}", "(BLOCK (PARA (ENDNOTE ((\"-\" . \"\")) (TEXT \"abc\"))))"}, }) - checkTcs(t, true, TestCases{ + checkTcs(t, TestCases{ {"[^abc\n\ndef]", "(BLOCK (PARA (TEXT \"[^abc\")) (PARA (TEXT \"def]\")))"}, }) } func TestMark(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"[!", "(INLINE (TEXT \"[!\"))"}, - {"[!\n", "(INLINE (TEXT \"[!\"))"}, - {"[!]", "(INLINE (MARK \"\" \"\" \"\"))"}, - {"[!][!]", "(INLINE (MARK \"\" \"\" \"\") (MARK \"\" \"\" \"\"))"}, - {"[! ]", "(INLINE (TEXT \"[! ]\"))"}, - {"[!a]", "(INLINE (MARK \"a\" \"\" \"\"))"}, - {"[!a][!a]", "(INLINE (MARK \"a\" \"\" \"\") (MARK \"a\" \"\" \"\"))"}, - {"[!a ]", "(INLINE (TEXT \"[!a ]\"))"}, - {"[!a_]", "(INLINE (MARK \"a_\" \"\" \"\"))"}, - {"[!a_][!a]", "(INLINE (MARK \"a_\" \"\" \"\") (MARK \"a\" \"\" \"\"))"}, - {"[!a-b]", "(INLINE (MARK \"a-b\" \"\" \"\"))"}, - {"[!a|b]", "(INLINE (MARK \"a\" \"\" \"\" (TEXT \"b\")))"}, - {"[!a|]", "(INLINE (MARK \"a\" \"\" \"\"))"}, - {"[!|b]", "(INLINE (MARK \"\" \"\" \"\" (TEXT \"b\")))"}, - {"[!|b ]", "(INLINE (MARK \"\" \"\" \"\" (TEXT \"b\")))"}, - {"[!|b c]", "(INLINE (MARK \"\" \"\" \"\" (TEXT \"b c\")))"}, + checkTcs(t, TestCases{ + {"[!", "(BLOCK (PARA (TEXT \"[!\")))"}, + {"[!\n", "(BLOCK (PARA (TEXT \"[!\")))"}, + {"[!]", "(BLOCK (PARA (MARK \"\" \"\" \"\")))"}, + {"[!][!]", "(BLOCK (PARA (MARK \"\" \"\" \"\") (MARK \"\" \"\" \"\")))"}, + {"[! ]", "(BLOCK (PARA (TEXT \"[! ]\")))"}, + {"[!a]", "(BLOCK (PARA (MARK \"a\" \"\" \"\")))"}, + {"[!a][!a]", "(BLOCK (PARA (MARK \"a\" \"\" \"\") (MARK \"a\" \"\" \"\")))"}, + {"[!a ]", "(BLOCK (PARA (TEXT \"[!a ]\")))"}, + {"[!a_]", "(BLOCK (PARA (MARK \"a_\" \"\" \"\")))"}, + {"[!a_][!a]", "(BLOCK (PARA (MARK \"a_\" \"\" \"\") (MARK \"a\" \"\" \"\")))"}, + {"[!a-b]", "(BLOCK (PARA (MARK \"a-b\" \"\" \"\")))"}, + {"[!a|b]", "(BLOCK (PARA (MARK \"a\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!a|]", "(BLOCK (PARA (MARK \"a\" \"\" \"\")))"}, + {"[!|b]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!|b ]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!|b c]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b c\"))))"}, }) } func TestComment(t *testing.T) { t.Parallel() - checkTcs(t, false, TestCases{ - {"%", "(INLINE (TEXT \"%\"))"}, - {"%%", "(INLINE (LITERAL-COMMENT () \"\"))"}, - {"%\n", "(INLINE (TEXT \"%\"))"}, - {"%%\n", "(INLINE (LITERAL-COMMENT () \"\"))"}, - {"%%a", "(INLINE (LITERAL-COMMENT () \"a\"))"}, - {"%%%a", "(INLINE (LITERAL-COMMENT () \"a\"))"}, - {"%% a", "(INLINE (LITERAL-COMMENT () \"a\"))"}, - {"%%% a", "(INLINE (LITERAL-COMMENT () \"a\"))"}, - {"%% % a", "(INLINE (LITERAL-COMMENT () \"% a\"))"}, - {"%%a", "(INLINE (LITERAL-COMMENT () \"a\"))"}, - {"a%%b", "(INLINE (TEXT \"a\") (LITERAL-COMMENT () \"b\"))"}, - {"a %%b", "(INLINE (TEXT \"a \") (LITERAL-COMMENT () \"b\"))"}, - {" %%b", "(INLINE (LITERAL-COMMENT () \"b\"))"}, - {"%%b ", "(INLINE (LITERAL-COMMENT () \"b \"))"}, - {"100%", "(INLINE (TEXT \"100%\"))"}, - {"%%{=}a", "(INLINE (LITERAL-COMMENT ((\"\" . \"\")) \"a\"))"}, + checkTcs(t, TestCases{ + {"%", "(BLOCK (PARA (TEXT \"%\")))"}, + {"%%", "(BLOCK (PARA (LITERAL-COMMENT () \"\")))"}, + {"%\n", "(BLOCK (PARA (TEXT \"%\")))"}, + {"%%\n", "(BLOCK (PARA (LITERAL-COMMENT () \"\")))"}, + {"%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%% a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%%% a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%% % a", "(BLOCK (PARA (LITERAL-COMMENT () \"% a\")))"}, + {"%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"a%%b", "(BLOCK (PARA (TEXT \"a\") (LITERAL-COMMENT () \"b\")))"}, + {"a %%b", "(BLOCK (PARA (TEXT \"a \") (LITERAL-COMMENT () \"b\")))"}, + {" %%b", "(BLOCK (PARA (LITERAL-COMMENT () \"b\")))"}, + {"%%b ", "(BLOCK (PARA (LITERAL-COMMENT () \"b \")))"}, + {"100%", "(BLOCK (PARA (TEXT \"100%\")))"}, + {"%%{=}a", "(BLOCK (PARA (LITERAL-COMMENT ((\"\" . \"\")) \"a\")))"}, }) } func TestFormat(t *testing.T) { symMap := symbolMap{ @@ -335,184 +324,179 @@ } t.Parallel() // Not for Insert / '>', because collision with quoted list // Not for Quote / '"', because escaped representation. for _, ch := range []string{"_", "*", "~", "^", ",", "#", ":"} { - checkTcs(t, false, replace(ch, symMap, TestCases{ - {"$", "(INLINE (TEXT \"$\"))"}, - {"$$", "(INLINE (TEXT \"$$\"))"}, - {"$$$", "(INLINE (TEXT \"$$$\"))"}, - {"$$$$", "(INLINE ($% ()))"}, + checkTcs(t, replace(ch, symMap, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"$\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$$$", "(BLOCK (PARA ($% ())))"}, })) } // Not for Quote / '"', because escaped representation. for _, ch := range []string{"_", "*", ">", "~", "^", ",", "#", ":"} { - checkTcs(t, false, replace(ch, symMap, TestCases{ - {"$$a$$", "(INLINE ($% () (TEXT \"a\")))"}, - {"$$a$$$", "(INLINE ($% () (TEXT \"a\")) (TEXT \"$\"))"}, - {"$$$a$$", "(INLINE ($% () (TEXT \"$a\")))"}, - {"$$$a$$$", "(INLINE ($% () (TEXT \"$a\")) (TEXT \"$\"))"}, - {"$\\$", "(INLINE (TEXT \"$$\"))"}, - {"$\\$$", "(INLINE (TEXT \"$$$\"))"}, - {"$$\\$", "(INLINE (TEXT \"$$$\"))"}, - {"$$a\\$$", "(INLINE (TEXT \"$$a$$\"))"}, - {"$$a$\\$", "(INLINE (TEXT \"$$a$$\"))"}, - {"$$a\\$$$", "(INLINE ($% () (TEXT \"a$\")))"}, - {"$$a\na$$", "(INLINE ($% () (TEXT \"a\") (SOFT) (TEXT \"a\")))"}, - {"$$a\n\na$$", "(INLINE (TEXT \"$$a\") (SOFT) (SOFT) (TEXT \"a$$\"))"}, - {"$$a$${go}", "(INLINE ($% ((\"go\" . \"\")) (TEXT \"a\")))"}, + checkTcs(t, replace(ch, symMap, TestCases{ + {"$$a$$", "(BLOCK (PARA ($% () (TEXT \"a\"))))"}, + {"$$a$$$", "(BLOCK (PARA ($% () (TEXT \"a\")) (TEXT \"$\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () (TEXT \"$a\"))))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () (TEXT \"$a\")) (TEXT \"$\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () (TEXT \"a$\"))))"}, + {"$$a\na$$", "(BLOCK (PARA ($% () (TEXT \"a\") (SOFT) (TEXT \"a\"))))"}, + {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"$$a\")) (PARA (TEXT \"a$$\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) (TEXT \"a\"))))"}, })) - checkTcs(t, true, replace(ch, symMap, TestCases{ + checkTcs(t, replace(ch, symMap, TestCases{ {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"$$a\")) (PARA (TEXT \"a$$\")))"}, })) } - checkTcs(t, false, replace(`"`, symbolMap{`"`: sz.SymFormatQuote}, TestCases{ - {"$", "(INLINE (TEXT \"\\\"\"))"}, - {"$$", "(INLINE (TEXT \"\\\"\\\"\"))"}, - {"$$$", "(INLINE (TEXT \"\\\"\\\"\\\"\"))"}, - {"$$$$", "(INLINE ($% ()))"}, - - {"$$a$$", "(INLINE ($% () (TEXT \"a\")))"}, - {"$$a$$$", "(INLINE ($% () (TEXT \"a\")) (TEXT \"\\\"\"))"}, - {"$$$a$$", "(INLINE ($% () (TEXT \"\\\"a\")))"}, - {"$$$a$$$", "(INLINE ($% () (TEXT \"\\\"a\")) (TEXT \"\\\"\"))"}, - {"$\\$", "(INLINE (TEXT \"\\\"\\\"\"))"}, - {"$\\$$", "(INLINE (TEXT \"\\\"\\\"\\\"\"))"}, - {"$$\\$", "(INLINE (TEXT \"\\\"\\\"\\\"\"))"}, - {"$$a\\$$", "(INLINE (TEXT \"\\\"\\\"a\\\"\\\"\"))"}, - {"$$a$\\$", "(INLINE (TEXT \"\\\"\\\"a\\\"\\\"\"))"}, - {"$$a\\$$$", "(INLINE ($% () (TEXT \"a\\\"\")))"}, - {"$$a\na$$", "(INLINE ($% () (TEXT \"a\") (SOFT) (TEXT \"a\")))"}, - {"$$a\n\na$$", "(INLINE (TEXT \"\\\"\\\"a\") (SOFT) (SOFT) (TEXT \"a\\\"\\\"\"))"}, - {"$$a$${go}", "(INLINE ($% ((\"go\" . \"\")) (TEXT \"a\")))"}, + checkTcs(t, replace(`"`, symbolMap{`"`: sz.SymFormatQuote}, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"\\\"\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$$$", "(BLOCK (PARA ($% ())))"}, + + {"$$a$$", "(BLOCK (PARA ($% () (TEXT \"a\"))))"}, + {"$$a$$$", "(BLOCK (PARA ($% () (TEXT \"a\")) (TEXT \"\\\"\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () (TEXT \"\\\"a\"))))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () (TEXT \"\\\"a\")) (TEXT \"\\\"\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\\\"\\\"\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\\\"\\\"\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () (TEXT \"a\\\"\"))))"}, + {"$$a\na$$", "(BLOCK (PARA ($% () (TEXT \"a\") (SOFT) (TEXT \"a\"))))"}, + {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\")) (PARA (TEXT \"a\\\"\\\"\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) (TEXT \"a\"))))"}, })) - checkTcs(t, false, TestCases{ - {"__****__", "(INLINE (FORMAT-EMPH () (FORMAT-STRONG ())))"}, - {"__**a**__", "(INLINE (FORMAT-EMPH () (FORMAT-STRONG () (TEXT \"a\"))))"}, - {"__**__**", "(INLINE (TEXT \"__\") (FORMAT-STRONG () (TEXT \"__\")))"}, + checkTcs(t, TestCases{ + {"__****__", "(BLOCK (PARA (FORMAT-EMPH () (FORMAT-STRONG ()))))"}, + {"__**a**__", "(BLOCK (PARA (FORMAT-EMPH () (FORMAT-STRONG () (TEXT \"a\")))))"}, + {"__**__**", "(BLOCK (PARA (TEXT \"__\") (FORMAT-STRONG () (TEXT \"__\"))))"}, }) } func TestLiteral(t *testing.T) { symMap := symbolMap{ - "@": sz.SymLiteralZettel, - "`": sz.SymLiteralProg, + "`": sz.SymLiteralCode, "'": sz.SymLiteralInput, "=": sz.SymLiteralOutput, } t.Parallel() - for _, ch := range []string{"@", "`", "'", "="} { - checkTcs(t, false, replace(ch, symMap, TestCases{ - {"$", "(INLINE (TEXT \"$\"))"}, - {"$$", "(INLINE (TEXT \"$$\"))"}, - {"$$$", "(INLINE (TEXT \"$$$\"))"}, - {"$$$$", "(INLINE ($% () \"\"))"}, - {"$$a$$", "(INLINE ($% () \"a\"))"}, - {"$$a$$$", "(INLINE ($% () \"a\") (TEXT \"$\"))"}, - {"$$$a$$", "(INLINE ($% () \"$a\"))"}, - {"$$$a$$$", "(INLINE ($% () \"$a\") (TEXT \"$\"))"}, - {"$\\$", "(INLINE (TEXT \"$$\"))"}, - {"$\\$$", "(INLINE (TEXT \"$$$\"))"}, - {"$$\\$", "(INLINE (TEXT \"$$$\"))"}, - {"$$a\\$$", "(INLINE (TEXT \"$$a$$\"))"}, - {"$$a$\\$", "(INLINE (TEXT \"$$a$$\"))"}, - {"$$a\\$$$", "(INLINE ($% () \"a$\"))"}, - {"$$a$${go}", "(INLINE ($% ((\"go\" . \"\")) \"a\"))"}, + for _, ch := range []string{"`", "'", "="} { + checkTcs(t, replace(ch, symMap, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"$\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$$$", "(BLOCK (PARA ($% () \"\")))"}, + {"$$a$$", "(BLOCK (PARA ($% () \"a\")))"}, + {"$$a$$$", "(BLOCK (PARA ($% () \"a\") (TEXT \"$\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () \"$a\")))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () \"$a\") (TEXT \"$\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () \"a$\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) \"a\")))"}, })) } - checkTcs(t, false, TestCases{ - {"''````''", "(INLINE (LITERAL-INPUT () \"````\"))"}, - {"''``a``''", "(INLINE (LITERAL-INPUT () \"``a``\"))"}, - {"''``''``", "(INLINE (LITERAL-INPUT () \"``\") (TEXT \"``\"))"}, - {"''\\'''", "(INLINE (LITERAL-INPUT () \"'\"))"}, - }) - checkTcs(t, false, TestCases{ - {"@@HTML@@{=html}", "(INLINE (LITERAL-HTML () \"HTML\"))"}, - {"@@HTML@@{=html lang=en}", "(INLINE (LITERAL-HTML ((\"lang\" . \"en\")) \"HTML\"))"}, - {"@@HTML@@{=html,lang=en}", "(INLINE (LITERAL-HTML ((\"lang\" . \"en\")) \"HTML\"))"}, + checkTcs(t, TestCases{ + {"``