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 { |
| 73 | private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass { |
73 | private 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 }) |