Sources/OrgSwift/Skeleton.swift
218 lines · 8298 bytes
1// HTML → semantic skeleton reduction.
2//
3// A faithful port of orgo's `skeleton()` (src/skeleton.rs) and the contract described in
4// the org-conformance corpus (SKELETON.md). It exists so OrgSwift can be measured against
5// the same goldens every other org renderer is: reduce this renderer's HTML to a
6// skeleton, reduce orgo's reference HTML to a skeleton, and compare. The two renderers
7// wrap and class things completely differently; the skeleton is the part they can
8// meaningfully agree on.
9//
10// The reduction must be byte-identical to orgo's, or a comparison means nothing. The
11// package's conformance test self-checks that first, by feeding each corpus `.html`
12// through this port and requiring it to reproduce the checked-in `.skeleton`.
13
14import Foundation
15
16public enum OrgSkeleton {
17 /// Elements dropped entirely: `div` is layout, `span` is per-token highlighter noise.
18 private static let ignored: Set<String> = ["div", "span"]
19
20 /// The only content-bearing attributes; everything else is generated or cosmetic.
21 private static let keptAttrs = ["href", "src"]
22
23 /// HTML void elements never emit a close event.
24 private static let void: Set<String> = [
25 "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
26 ]
27
28 /// Reduce an HTML fragment to one line per element open, element close, or text run.
29 public static func skeleton(_ html: String) -> [String] {
30 var out: [String] = []
31 let chars = Array(html)
32 var i = 0
33 var text = ""
34
35 while i < chars.count {
36 if chars[i] != "<" {
37 text.append(chars[i])
38 i += 1
39 continue
40 }
41
42 // Comments and doctypes carry nothing.
43 if i + 1 < chars.count, chars[i + 1] == "!" {
44 if let end = findFrom(chars, i, ">") {
45 i = end + 1
46 } else {
47 break
48 }
49 continue
50 }
51 guard let end = findFrom(chars, i, ">") else { break }
52 let rawFull = String(chars[(i + 1)..<end])
53 i = end + 1
54
55 // trim → strip trailing '/' → trim
56 var raw = rawFull.trimmingCharacters(in: .whitespaces)
57 if raw.hasSuffix("/") { raw.removeLast() }
58 raw = raw.trimmingCharacters(in: .whitespaces)
59
60 if raw.hasPrefix("/") {
61 let name = raw.dropFirst().trimmingCharacters(in: .whitespaces).lowercased()
62 if !ignored.contains(name) && !void.contains(name) {
63 flushText(&text, &out)
64 out.append("</\(name)>")
65 }
66 continue
67 }
68
69 let (name, rest) = splitFirstWhitespace(raw)
70 let lname = name.lowercased()
71 if lname.isEmpty || ignored.contains(lname) {
72 continue
73 }
74 let attrs = keptAttributes(rest)
75 flushText(&text, &out)
76 out.append("<\(lname)\(attrs)>")
77 }
78 flushText(&text, &out)
79 return out
80 }
81
82 private static func flushText(_ text: inout String, _ out: inout [String]) {
83 let decoded = decodeEntities(text)
84 let collapsed = decoded.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
85 if !collapsed.isEmpty {
86 out.append(debugQuote(collapsed))
87 }
88 text = ""
89 }
90
91 /// Reproduce Rust's `format!("{:?}", s)` for whitespace-collapsed text: wrap in double
92 /// quotes and escape `\` and `"`. No control characters survive whitespace collapse,
93 /// so no further escaping is needed to match the goldens.
94 private static func debugQuote(_ s: String) -> String {
95 var escaped = ""
96 escaped.reserveCapacity(s.count + 2)
97 for c in s {
98 if c == "\\" || c == "\"" { escaped.append("\\") }
99 escaped.append(c)
100 }
101 return "\"\(escaped)\""
102 }
103
104 private static func findFrom(_ chars: [Character], _ from: Int, _ needle: Character) -> Int? {
105 var k = from
106 while k < chars.count {
107 if chars[k] == needle { return k }
108 k += 1
109 }
110 return nil
111 }
112
113 private static func splitFirstWhitespace(_ s: String) -> (String, String) {
114 guard let idx = s.firstIndex(where: { $0.isWhitespace }) else { return (s, "") }
115 let name = String(s[s.startIndex..<idx])
116 let rest = String(s[s.index(after: idx)...])
117 return (name, rest)
118 }
119
120 private static func keptAttributes(_ rest: String) -> String {
121 var result = ""
122 for attr in keptAttrs {
123 if let value = attributeValue(rest, attr) {
124 result += " \(attr)=\"\(decodeEntities(value))\""
125 }
126 }
127 return result
128 }
129
130 private static func attributeValue(_ rest: String, _ name: String) -> String? {
131 let chars = Array(rest)
132 let nameChars = Array(name)
133 var search = 0
134 while let pos = indexOf(chars, nameChars, from: search) {
135 let beforeOK = pos == 0 || chars[pos - 1].isWhitespace
136 var after = pos + nameChars.count
137 while after < chars.count && chars[after].isWhitespace { after += 1 }
138 if beforeOK && after < chars.count && chars[after] == "=" {
139 var value = after + 1
140 while value < chars.count && chars[value].isWhitespace { value += 1 }
141 guard value < chars.count else { return nil }
142 let quote = chars[value]
143 if quote == "\"" || quote == "'" {
144 var end = value + 1
145 while end < chars.count && chars[end] != quote { end += 1 }
146 guard end < chars.count else { return nil }
147 return String(chars[(value + 1)..<end])
148 }
149 var end = value
150 while end < chars.count && !chars[end].isWhitespace { end += 1 }
151 return String(chars[value..<end])
152 }
153 search = pos + nameChars.count
154 }
155 return nil
156 }
157
158 private static func indexOf(_ haystack: [Character], _ needle: [Character], from: Int) -> Int? {
159 if needle.isEmpty { return from }
160 var k = from
161 while k + needle.count <= haystack.count {
162 if Array(haystack[k..<(k + needle.count)]) == needle { return k }
163 k += 1
164 }
165 return nil
166 }
167
168 /// Decode the entities either exporter is likely to emit.
169 static func decodeEntities(_ s: String) -> String {
170 var out = ""
171 out.reserveCapacity(s.count)
172 let chars = Array(s)
173 var i = 0
174 while i < chars.count {
175 guard chars[i] == "&" else { out.append(chars[i]); i += 1; continue }
176 // find ';' within 12 chars
177 var semi: Int? = nil
178 var k = i + 1
179 while k < chars.count && k - i <= 12 {
180 if chars[k] == ";" { semi = k; break }
181 k += 1
182 }
183 guard let semiIdx = semi else { out.append("&"); i += 1; continue }
184 let entity = String(chars[(i + 1)..<semiIdx])
185 let decoded = decodeEntity(entity)
186 if let d = decoded {
187 out.append(d == "\u{a0}" ? " " : String(d))
188 i = semiIdx + 1
189 } else {
190 out.append("&")
191 i += 1
192 }
193 }
194 return out
195 }
196
197 private static func decodeEntity(_ entity: String) -> Character? {
198 switch entity {
199 case "amp": return "&"
200 case "lt": return "<"
201 case "gt": return ">"
202 case "quot": return "\""
203 case "apos": return "'"
204 case "nbsp": return "\u{a0}"
205 default:
206 guard entity.hasPrefix("#") else { return nil }
207 let num = String(entity.dropFirst())
208 let scalar: UInt32?
209 if num.hasPrefix("x") || num.hasPrefix("X") {
210 scalar = UInt32(num.dropFirst(), radix: 16)
211 } else {
212 scalar = UInt32(num)
213 }
214 guard let s = scalar, let u = Unicode.Scalar(s) else { return nil }
215 return Character(u)
216 }
217 }
218}