krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 5d3019a3c9

5d3019a3c99437780e1c4a6c913a9afbaca3b13f

parent: ecf09db5d3

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:30 UTC

Match org's heading and keyword rules

Layout: unified · split

Sources/OrgCore/Parser/Lines.swift +3 −2
@@ -73,10 +73,11 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
73private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass { 73private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
74 let trimmed = rest.trimmingTrailingWhitespace 74 let trimmed = rest.trimmingTrailingWhitespace
75 75
76 // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
77 // is not a heading.
76 if columnZero, rest.first == "*" { 78 if columnZero, rest.first == "*" {
77 let stars = rest.prefix { $0 == "*" } 79 let stars = rest.prefix { $0 == "*" }
78 let after = rest.dropFirst(stars.count) 80 if rest.dropFirst(stars.count).first == " " {
79 if after.isEmpty || after.first == " " || after.first == "\t" {
80 return .heading(level: stars.count) 81 return .heading(level: stars.count)
81 } 82 }
82 } 83 }
Sources/OrgCore/Parser/Parser.swift +3 −1
@@ -390,8 +390,10 @@ struct Parser {
390 builder.token(.stars, stars) 390 builder.token(.stars, stars)
391 rest = whitespace(rest.dropFirst(stars.count)) 391 rest = whitespace(rest.dropFirst(stars.count))
392 392
393 // As org: a keyword counts only before a space or the end of the line, not a tab.
393 let word = rest.prefix { $0 != " " && $0 != "\t" } 394 let word = rest.prefix { $0 != " " && $0 != "\t" }
394 if !word.isEmpty, settings.todoKeywordNames.contains(String(word)) { 395 let afterWord = rest.dropFirst(word.count).first
396 if !word.isEmpty, afterWord == nil || afterWord == " ", settings.todoKeywordNames.contains(String(word)) {
395 builder.token(.todoKeyword, word) 397 builder.token(.todoKeyword, word)
396 rest = whitespace(rest.dropFirst(word.count)) 398 rest = whitespace(rest.dropFirst(word.count))
397 } 399 }
Tests/OrgCoreTests/LinesTests.swift +3 −1
@@ -20,7 +20,9 @@ struct LinesTests {
20 (" \t", .blank), 20 (" \t", .blank),
21 ("* a", .heading(level: 1)), 21 ("* a", .heading(level: 1)),
22 ("*** ", .heading(level: 3)), 22 ("*** ", .heading(level: 3)),
23 ("*", .heading(level: 1)), 23 ("*", .plain),
24 ("*\ttab", .plain),
25 ("* ", .heading(level: 1)),
24 ("*bold* text", .plain), 26 ("*bold* text", .plain),
25 (" * a", .listItem), 27 (" * a", .listItem),
26 ("#+BEGIN_SRC sh :results output", .blockBegin(name: "src")), 28 ("#+BEGIN_SRC sh :results output", .blockBegin(name: "src")),
Tests/OrgCoreTests/ParserSectionTests.swift +6
@@ -43,6 +43,12 @@ struct ParserSectionTests {
43 #expect(todo == ["NEXT"]) 43 #expect(todo == ["NEXT"])
44 } 44 }
45 45
46 @Test func todoKeywordNeedsASpaceAfterIt() {
47 #expect(tokens(of: .heading, in: "* TODO a\n").contains { $0.kind == .todoKeyword })
48 #expect(tokens(of: .heading, in: "* TODO\n").contains { $0.kind == .todoKeyword })
49 #expect(!tokens(of: .heading, in: "* TODO\ta\n").contains { $0.kind == .todoKeyword })
50 }
51
46 @Test func priorityNeedsValidValueAndSpace() { 52 @Test func priorityNeedsValidValueAndSpace() {
47 #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority }) 53 #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority })
48 #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority }) 54 #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority })