aboutsummaryrefslogtreecommitdiff
path: root/Sources/WmapParser
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2025-12-13 01:17:52 +0100
committerRuben Beltran del Rio <jj@r.bdr.sh>2025-12-14 09:15:07 +0100
commit03f1a7389afeadf9aa2b1d62a38e5cb0a2a7e60d (patch)
tree2efefc34a61c62d55d8436180acfc3590287f5fd /Sources/WmapParser
Initial implementation
Diffstat (limited to 'Sources/WmapParser')
-rw-r--r--Sources/WmapParser/Component.swift6
-rw-r--r--Sources/WmapParser/ComponentParser.swift78
-rw-r--r--Sources/WmapParser/Constants.swift35
-rw-r--r--Sources/WmapParser/Dependency.swift6
-rw-r--r--Sources/WmapParser/DependencyParser.swift50
-rw-r--r--Sources/WmapParser/Evolution.swift5
-rw-r--r--Sources/WmapParser/Group.swift4
-rw-r--r--Sources/WmapParser/Inertia.swift4
-rw-r--r--Sources/WmapParser/KeywordParser.swift184
-rw-r--r--Sources/WmapParser/KeywordParserContext.swift7
-rw-r--r--Sources/WmapParser/Map.swift10
-rw-r--r--Sources/WmapParser/MapPoint.swift5
-rw-r--r--Sources/WmapParser/Note.swift5
-rw-r--r--Sources/WmapParser/Shape.swift8
-rw-r--r--Sources/WmapParser/Stage.swift5
-rw-r--r--Sources/WmapParser/StageNumber.swift8
-rw-r--r--Sources/WmapParser/Utils.swift136
-rw-r--r--Sources/WmapParser/WmapParser.swift79
18 files changed, 635 insertions, 0 deletions
diff --git a/Sources/WmapParser/Component.swift b/Sources/WmapParser/Component.swift
new file mode 100644
index 0000000..027ac59
--- /dev/null
+++ b/Sources/WmapParser/Component.swift
@@ -0,0 +1,6 @@
+/// A component in the map.
+public struct Component: Sendable, Equatable {
+ public let label: String
+ public let coordinates: MapPoint
+ public let shape: Shape
+}
diff --git a/Sources/WmapParser/ComponentParser.swift b/Sources/WmapParser/ComponentParser.swift
new file mode 100644
index 0000000..c899637
--- /dev/null
+++ b/Sources/WmapParser/ComponentParser.swift
@@ -0,0 +1,78 @@
+/// Parses a component with its coordinates.
+func parseComponent<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Component?
+where T.Element == UInt8, T.Index == Int {
+ guard let (label, parenthesisIndex) = extractComponentLabel(bytes, start, end) else {
+ return nil
+ }
+
+ guard
+ let (coordinates, endIndex) = parseCoordinates(
+ bytes, parenthesisIndex + 1, end)
+ else {
+ return nil
+ }
+
+ let shape = extractComponentShape(bytes, endIndex, end)
+
+ return Component(label: label, coordinates: coordinates, shape: shape)
+}
+
+/// Looks for a component label by taking the string before the first open
+/// parenthesis.
+@inline(__always)
+private func extractComponentLabel<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> (
+ String, Int
+)?
+where T.Element == UInt8, T.Index == Int {
+ var index = start
+ while index < end {
+ if bytes[index] == kOpenParenthesis {
+ guard index > start else { return nil }
+ guard let label = extractTrimmedString(bytes, start, index) else { return nil }
+ return (label, index)
+ }
+ index += 1
+ }
+ return nil
+}
+
+/// Extracts a shape by looking for a shape label in square brackets.
+/// If missing, incomplete or invalid, it returns a circle
+@inline(__always)
+private func extractComponentShape<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Shape
+where T.Element == UInt8, T.Index == Int {
+ var index = skipWhitespace(bytes, end, start)
+
+ guard index < end && bytes[index] == kOpenSquareBracket else { return .circle }
+
+ index += 1
+ let shapeStart = index
+ while index < end && bytes[index] != kCloseSquareBracket { index += 1 }
+
+ guard index > shapeStart && index < end else { return .circle }
+
+ let shapeName = bytes[shapeStart..<index]
+ return parseShapeFromBytes(shapeName)
+}
+
+/// Given the contents of a shape label, return the right type.
+@inline(__always)
+private func parseShapeFromBytes<T: Collection>(_ shapeName: T) -> Shape
+where T.Element == UInt8, T.Index == Int {
+ var trimStart = shapeName.startIndex
+ var trimEnd = shapeName.endIndex
+
+ trimStart = skipWhitespace(shapeName, trimEnd, trimStart)
+ trimEnd = shaveWhitespace(shapeName, trimStart, trimEnd)
+
+ let trimmedName = shapeName[trimStart..<trimEnd]
+
+ // "x"
+ if matchesKeyword(trimmedName, [120]) { return .xMark }
+ // "square"
+ if matchesKeyword(trimmedName, [115, 113, 117, 97, 114, 101]) { return .square }
+ // "triangle"
+ if matchesKeyword(trimmedName, [116, 114, 105, 97, 110, 103, 108, 101]) { return .triangle }
+
+ return .circle
+}
diff --git a/Sources/WmapParser/Constants.swift b/Sources/WmapParser/Constants.swift
new file mode 100644
index 0000000..73340b6
--- /dev/null
+++ b/Sources/WmapParser/Constants.swift
@@ -0,0 +1,35 @@
+// Whitespace
+let kNewline = 10
+let kCarriageReturn = 13
+let kTab = 9
+let kSpace = 32
+
+// Punctuation
+let kOpenParenthesis = 40
+let kCloseParenthesis = 41
+let kPlus = 43
+let kComma = 44
+let kDash = 45
+let kPeriod = 46
+let kGreaterThan = 62
+let kOpenSquareBracket = 91
+let kCloseSquareBracket = 93
+
+// Letters
+let kCapitalA = 65
+let kCapitalZ = 90
+let kCapsToLowercaseDistance: UInt8 = 32
+
+// Numbers
+let kZero = 48
+let kNine = 57
+
+// Keywords. ASCII representations of the keywords.
+let kInertiaKeyword: [UInt8] = [105, 110, 101, 114, 116, 105, 97]
+let kEvolutionKeyword: [UInt8] = [101, 118, 111, 108, 117, 116, 105, 111, 110]
+let kNoteKeyword: [UInt8] = [110, 111, 116, 101]
+let kGroupKeyword: [UInt8] = [103, 114, 111, 117, 112]
+let kIKeyword: [UInt8] = [105]
+let kIIKeyword: [UInt8] = [105, 105]
+let kIIIKeyword: [UInt8] = [105, 105, 105]
+let kIVKeyword: [UInt8] = [105, 118]
diff --git a/Sources/WmapParser/Dependency.swift b/Sources/WmapParser/Dependency.swift
new file mode 100644
index 0000000..38f03be
--- /dev/null
+++ b/Sources/WmapParser/Dependency.swift
@@ -0,0 +1,6 @@
+/// A dependency between two components.
+public struct Dependency: Sendable, Equatable {
+ public let fromComponent: String
+ public let toComponent: String
+ public let isDirected: Bool
+}
diff --git a/Sources/WmapParser/DependencyParser.swift b/Sources/WmapParser/DependencyParser.swift
new file mode 100644
index 0000000..9964d5a
--- /dev/null
+++ b/Sources/WmapParser/DependencyParser.swift
@@ -0,0 +1,50 @@
+/// Looks for -- or ->. If found, returns where this arrow starts.
+@inline(__always)
+func hasDependency<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Int?
+where T.Element == UInt8, T.Index == Int {
+ var index = start
+ while index < end {
+ if bytes[index] == kOpenParenthesis { return nil }
+ if bytes[index] == kDash && index + 1 < end {
+ let next = bytes[index + 1]
+ if next == kGreaterThan || next == kDash { return index }
+ }
+ index += 1
+ }
+ return nil
+}
+
+/// Finds a dependency and whether it's directed. It assumes we previously
+/// checked for the location of our separator to speed things up.
+func parseDependency<T: Collection>(_ bytes: T, _ start: Int, _ end: Int, _ separatorIndex: Int)
+ -> Dependency?
+where T.Element == UInt8, T.Index == Int {
+ guard let isDirected = extractIsDirected(bytes, separatorIndex, end) else {
+ return nil
+ }
+
+ guard let fromComponent = extractTrimmedString(bytes, start, separatorIndex) else {
+ return nil
+ }
+
+ guard let toComponent = extractTrimmedString(bytes, separatorIndex + 2, end) else {
+ return nil
+ }
+
+ return Dependency(fromComponent: fromComponent, toComponent: toComponent, isDirected: isDirected)
+}
+
+/// Checks the second character of the separator to see if it's directed.
+/// It assumes we already checked for -- or ->, so it only checks for
+/// Whether it's greaterThan or not.
+@inline(__always)
+private func extractIsDirected<T: Collection>(_ bytes: T, _ separatorIndex: Int, _ end: Int)
+ -> Bool?
+where T.Element == UInt8, T.Index == Int {
+ let followingCharacter = separatorIndex + 1
+ guard followingCharacter < end else { return nil }
+ if bytes[followingCharacter] == kGreaterThan {
+ return true
+ }
+ return false
+}
diff --git a/Sources/WmapParser/Evolution.swift b/Sources/WmapParser/Evolution.swift
new file mode 100644
index 0000000..df6dd33
--- /dev/null
+++ b/Sources/WmapParser/Evolution.swift
@@ -0,0 +1,5 @@
+/// Evolution associated with a component.
+public struct Evolution: Sendable, Equatable {
+ public let component: String
+ public let value: Double
+}
diff --git a/Sources/WmapParser/Group.swift b/Sources/WmapParser/Group.swift
new file mode 100644
index 0000000..5204d45
--- /dev/null
+++ b/Sources/WmapParser/Group.swift
@@ -0,0 +1,4 @@
+/// A group of components.
+public struct Group: Sendable, Equatable {
+ public let components: [String]
+}
diff --git a/Sources/WmapParser/Inertia.swift b/Sources/WmapParser/Inertia.swift
new file mode 100644
index 0000000..e9be9c0
--- /dev/null
+++ b/Sources/WmapParser/Inertia.swift
@@ -0,0 +1,4 @@
+/// Inertia associated with a component.
+public struct Inertia: Sendable, Equatable {
+ public let component: String
+}
diff --git a/Sources/WmapParser/KeywordParser.swift b/Sources/WmapParser/KeywordParser.swift
new file mode 100644
index 0000000..8db1164
--- /dev/null
+++ b/Sources/WmapParser/KeywordParser.swift
@@ -0,0 +1,184 @@
+/// Checks which keyword we're dealing with and routes it to the right parser.
+func parseKeywordLine<T: Collection>(
+ _ bytes: T, _ start: Int, _ end: Int,
+ _ context: inout KeywordParserContext
+) where T.Element == UInt8, T.Index == Int {
+ guard let (keyword, rest) = extractKeyword(bytes, start, end) else { return }
+
+ if matchesKeyword(keyword, kInertiaKeyword), let inertia = parseInertia(bytes, rest, end) {
+ context.inertias.append(inertia)
+ } else if matchesKeyword(keyword, kEvolutionKeyword),
+ let evolution = parseEvolution(bytes, rest, end) {
+ context.evolutions.append(evolution)
+ } else if matchesKeyword(keyword, kNoteKeyword), let note = parseNote(bytes, rest, end) {
+ context.notes.append(note)
+ } else if matchesKeyword(keyword, kGroupKeyword), let group = parseGroup(bytes, rest, end) {
+ context.groups.append(group)
+ } else if let stageNumber = isStageKeyword(keyword),
+ let stage = parseStage(bytes, stageNumber, rest, end) {
+ context.stages.append(stage)
+ }
+}
+
+/// Extracts a keyword from inside brackets. + the index of the rest of the
+/// line.
+@inline(__always)
+func extractKeyword<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> (T.SubSequence, Int)?
+where T.Element == UInt8, T.Index == Int {
+ var index = start + 1 // Skip '['
+ let keywordStart = index
+
+ while index < end && bytes[index] != kCloseSquareBracket { index += 1 } // Find ']'
+ if index >= end { return nil }
+
+ let keywordEnd = index
+ index += 1
+
+ // Skip whitespace after ']'
+ index = skipWhitespace(bytes, end, index)
+
+ return (bytes[keywordStart..<keywordEnd], index)
+}
+
+/// Checks the four potential values for the stage keyword.
+@inline(__always)
+func isStageKeyword<T: Collection>(_ keyword: T) -> StageNumber?
+where T.Element == UInt8, T.Index == Int {
+ if matchesKeyword(keyword, kIKeyword) {
+ return .stageI
+ }
+ if matchesKeyword(keyword, kIIKeyword) {
+ return .stageII
+ }
+ if matchesKeyword(keyword, kIIIKeyword) {
+ return .stageIII
+ }
+ if matchesKeyword(keyword, kIVKeyword) {
+ return .stageIV
+ }
+ return nil
+}
+
+/// Compares a collection of bytes against a list of bytes.
+/// Short-Circuits by length, only two have the same length (ii and iv)
+@inline(__always)
+func matchesKeyword<T: Collection>(_ keyword: T, _ expected: [UInt8]) -> Bool
+where T.Element == UInt8, T.Index == Int {
+ guard keyword.count == expected.count else { return false }
+ var keywordIndex = keyword.startIndex
+ for index in 0..<expected.count {
+ let currentKeyword = keyword[keywordIndex]
+ let expectedKeyword = expected[index]
+
+ let lowercasedKeyword =
+ (currentKeyword >= kCapitalA && currentKeyword <= kCapitalZ)
+ ? currentKeyword + kCapsToLowercaseDistance : currentKeyword
+ if lowercasedKeyword != expectedKeyword { return false }
+ keywordIndex += 1
+ }
+ return true
+}
+
+/// Parses the stage. Assumes we got the stage number in the keyword check
+/// to avoid checking twice.
+func parseStage<T: Collection>(
+ _ bytes: T, _ stageNumber: StageNumber, _ start: Int, _ end: Int
+) -> Stage?
+where T.Element == UInt8, T.Index == Int {
+ guard let value = parseDouble(bytes, start, end) else { return nil }
+
+ return Stage(stage: stageNumber, value: value)
+}
+
+/// Parses the note coordinates and text.
+func parseNote<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Note?
+where T.Element == UInt8, T.Index == Int {
+ var index = skipWhitespace(bytes, end, start)
+
+ guard index < end && bytes[index] == kOpenParenthesis else { return nil }
+ index += 1
+
+ guard let (coordinates, endIndex) = parseCoordinates(bytes, index, end) else {
+ return nil
+ }
+ index = skipWhitespace(bytes, end, endIndex)
+
+ let textEnd = shaveWhitespace(bytes, index, end)
+
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ let text = String(decoding: bytes[index..<textEnd], as: UTF8.self)
+ return Note(text: text, coordinates: coordinates)
+}
+
+/// Parses the comma separated labels for a group
+func parseGroup<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Group?
+where T.Element == UInt8, T.Index == Int {
+ var components = [String]()
+ var index = start
+ var componentStart = start
+
+ while index <= end {
+ if index == end || bytes[index] == kComma {
+ let trimStart = skipWhitespace(bytes, index, componentStart)
+ let trimEnd = shaveWhitespace(bytes, trimStart, index)
+
+ if trimStart < trimEnd {
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ let component = String(decoding: bytes[trimStart..<trimEnd], as: UTF8.self)
+ components.append(component)
+ }
+
+ componentStart = index + 1
+ }
+ index += 1
+ }
+
+ return components.isEmpty ? nil : Group(components: components)
+}
+
+/// Parses the component label for inertia
+func parseInertia<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Inertia?
+where T.Element == UInt8, T.Index == Int {
+ let trimStart = skipWhitespace(bytes, end, start)
+ let trimEnd = shaveWhitespace(bytes, trimStart, end)
+
+ guard trimStart < trimEnd else { return nil }
+
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ let component = String(decoding: bytes[trimStart..<trimEnd], as: UTF8.self)
+ return Inertia(component: component)
+}
+
+/// Parses the component label and value for inertia
+func parseEvolution<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Evolution?
+where T.Element == UInt8, T.Index == Int {
+ var signIndex: Int?
+ var sign = 1.0
+
+ var index = start
+ while index < end {
+ if bytes[index] == kPlus || bytes[index] == kDash {
+ signIndex = index
+ sign = bytes[index] == 45 ? -1.0 : 1.0
+ break
+ }
+ index += 1
+ }
+
+ guard let signIndex, signIndex > start else { return nil }
+
+ let trimStart = skipWhitespace(bytes, signIndex, start)
+ let trimEnd = shaveWhitespace(bytes, trimStart, signIndex)
+
+ guard trimStart < trimEnd else { return nil }
+
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ let component = String(decoding: bytes[trimStart..<trimEnd], as: UTF8.self)
+ guard let value = parseDouble(bytes, signIndex + 1, end) else { return nil }
+
+ return Evolution(component: component, value: sign * value)
+}
diff --git a/Sources/WmapParser/KeywordParserContext.swift b/Sources/WmapParser/KeywordParserContext.swift
new file mode 100644
index 0000000..1b889b3
--- /dev/null
+++ b/Sources/WmapParser/KeywordParserContext.swift
@@ -0,0 +1,7 @@
+struct KeywordParserContext {
+ var stages: [Stage]
+ var notes: [Note]
+ var groups: [Group]
+ var inertias: [Inertia]
+ var evolutions: [Evolution]
+}
diff --git a/Sources/WmapParser/Map.swift b/Sources/WmapParser/Map.swift
new file mode 100644
index 0000000..7a25686
--- /dev/null
+++ b/Sources/WmapParser/Map.swift
@@ -0,0 +1,10 @@
+/// A parsed wardley map.
+public struct Map: Sendable {
+ public let components: [Component]
+ public let dependencies: [Dependency]
+ public let notes: [Note]
+ public let stages: [Stage]
+ public let groups: [Group]
+ public let inertias: [Inertia]
+ public let evolutions: [Evolution]
+}
diff --git a/Sources/WmapParser/MapPoint.swift b/Sources/WmapParser/MapPoint.swift
new file mode 100644
index 0000000..718ecab
--- /dev/null
+++ b/Sources/WmapParser/MapPoint.swift
@@ -0,0 +1,5 @@
+/// A point in the map
+public struct MapPoint: Sendable, Equatable {
+ let xCoordinate: Double
+ let yCoordinate: Double
+}
diff --git a/Sources/WmapParser/Note.swift b/Sources/WmapParser/Note.swift
new file mode 100644
index 0000000..0db3739
--- /dev/null
+++ b/Sources/WmapParser/Note.swift
@@ -0,0 +1,5 @@
+/// A note.
+public struct Note: Sendable, Equatable {
+ public let text: String
+ public let coordinates: MapPoint
+}
diff --git a/Sources/WmapParser/Shape.swift b/Sources/WmapParser/Shape.swift
new file mode 100644
index 0000000..ea1844e
--- /dev/null
+++ b/Sources/WmapParser/Shape.swift
@@ -0,0 +1,8 @@
+/// Any of the potential shapes in a component.
+@frozen
+public enum Shape: Sendable, Equatable {
+ case circle
+ case xMark
+ case square
+ case triangle
+}
diff --git a/Sources/WmapParser/Stage.swift b/Sources/WmapParser/Stage.swift
new file mode 100644
index 0000000..78429e0
--- /dev/null
+++ b/Sources/WmapParser/Stage.swift
@@ -0,0 +1,5 @@
+/// An override for the width of an evolution stage.
+public struct Stage: Sendable, Equatable {
+ public let stage: StageNumber
+ public let value: Double
+}
diff --git a/Sources/WmapParser/StageNumber.swift b/Sources/WmapParser/StageNumber.swift
new file mode 100644
index 0000000..2e8a2da
--- /dev/null
+++ b/Sources/WmapParser/StageNumber.swift
@@ -0,0 +1,8 @@
+/// The potential stages of evolution.
+@frozen
+public enum StageNumber: Sendable, Equatable {
+ case stageI
+ case stageII
+ case stageIII
+ case stageIV
+}
diff --git a/Sources/WmapParser/Utils.swift b/Sources/WmapParser/Utils.swift
new file mode 100644
index 0000000..8bea13a
--- /dev/null
+++ b/Sources/WmapParser/Utils.swift
@@ -0,0 +1,136 @@
+/// Check if a byte is a digit
+@inline(__always)
+func isDigit(_ byte: UInt8) -> Bool {
+ byte >= kZero && byte <= kNine
+}
+
+/// Looks for x and y separated by a comma.
+func parseCoordinates<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> (
+ MapPoint, Int
+)?
+where T.Element == UInt8, T.Index == Int {
+ var index = skipWhitespace(bytes, end, start)
+
+ guard let (xCoordinate, nextIndex) = parseSingleCoordinate(bytes, index, end) else {
+ return nil
+ }
+ index = nextIndex
+
+ guard expectComma(bytes, &index, end) else { return nil }
+
+ guard let (yCoordinate, nextIndex) = parseSingleCoordinate(bytes, index, end) else {
+ return nil
+ }
+ index = nextIndex
+
+ guard expectClosingParen(bytes, &index, end) else { return nil }
+
+ return (MapPoint(xCoordinate: xCoordinate, yCoordinate: yCoordinate), index)
+}
+
+/// Checks if a candidate for a coordinate is a digit, and parses it.
+@inline(__always)
+func parseSingleCoordinate<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> (Double, Int)?
+where T.Element == UInt8, T.Index == Int {
+ var index = start
+ while index < end && (isDigit(bytes[index]) || bytes[index] == 46) { index += 1 }
+ guard index > start else { return nil }
+ guard let value = parseDouble(bytes, start, index) else { return nil }
+ return (value, index)
+}
+
+/// Advances the index until we find a comma.
+@inline(__always)
+func expectComma<T: Collection>(_ bytes: T, _ index: inout Int, _ end: Int) -> Bool
+where T.Element == UInt8, T.Index == Int {
+ index = skipWhitespace(bytes, end, index)
+ guard index < end && bytes[index] == kComma else { return false }
+ index += 1
+ index = skipWhitespace(bytes, end, index)
+ return true
+}
+
+/// Advances the index until we find a closing parenthesis
+@inline(__always)
+func expectClosingParen<T: Collection>(_ bytes: T, _ index: inout Int, _ end: Int) -> Bool
+where T.Element == UInt8, T.Index == Int {
+ index = skipWhitespace(bytes, end, index)
+ guard index < end && bytes[index] == kCloseParenthesis else { return false }
+ index += 1
+ return true
+}
+
+/// Parses a double
+@inline(__always)
+func parseDouble<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Double?
+where T.Element == UInt8, T.Index == Int {
+ var index = skipWhitespace(bytes, end, start)
+
+ let numStart = index
+ while index < end && (isDigit(bytes[index]) || bytes[index] == kPeriod) { index += 1 }
+
+ guard index > numStart else { return nil }
+
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ let str = String(decoding: bytes[numStart..<index], as: UTF8.self)
+ return Double(str)
+}
+
+/// Advances an index by looking for whitespace left-to-right
+func skipWhitespace<T: Collection>(_ bytes: T, _ end: Int, _ start: Int) -> Int
+where T.Element == UInt8, T.Index == Int {
+ var index = start
+ while index < end && (bytes[index] == kSpace || bytes[index] == kTab) { index += 1 }
+ return index
+}
+
+/// Decreases an index by looking for whitespace right-to-left
+func shaveWhitespace<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> Int
+where T.Element == UInt8, T.Index == Int {
+ var index = end
+ while index > start && (bytes[index - 1] == kSpace || bytes[index - 1] == kTab) { index -= 1 }
+ return index
+}
+
+/// Given the bytes buffer, finds the next non-empty line.
+@inline(__always)
+func extractLine<T: Collection>(_ bytes: T, _ index: inout Int, _ length: Int) -> (
+ Int, Int
+)?
+where T.Element == UInt8, T.Index == Int {
+ while index < length && (bytes[index] == kNewline || bytes[index] == kCarriageReturn) {
+ index += 1
+ }
+ let lineStart = index
+
+ while index < length && bytes[index] != kNewline && bytes[index] != kCarriageReturn { index += 1 }
+ if lineStart == index { return nil }
+
+ var start = lineStart
+ var end = index
+
+ start = skipWhitespace(bytes, end, start)
+ end = shaveWhitespace(bytes, start, end)
+
+ if start == end { return nil }
+
+ return (start, end)
+}
+
+/// Trims a byte collection and returns a string
+@inline(__always)
+func extractTrimmedString<T: Collection>(_ bytes: T, _ start: Int, _ end: Int) -> String?
+where T.Element == UInt8, T.Index == Int {
+ var trimStart = start
+ var trimEnd = end
+
+ trimStart = skipWhitespace(bytes, trimEnd, trimStart)
+ trimEnd = shaveWhitespace(bytes, trimStart, trimEnd)
+
+ guard trimStart < trimEnd else { return nil }
+
+ // Why not String(data:encoding:)? This is much faster.
+ // swiftlint:disable:next optional_data_string_conversion
+ return String(decoding: bytes[trimStart..<trimEnd], as: UTF8.self)
+}
diff --git a/Sources/WmapParser/WmapParser.swift b/Sources/WmapParser/WmapParser.swift
new file mode 100644
index 0000000..f2bef8f
--- /dev/null
+++ b/Sources/WmapParser/WmapParser.swift
@@ -0,0 +1,79 @@
+import Foundation
+
+// Public API //////////////////////////////////////////////////////////////////
+
+/// Parses a wmap source string.
+public func parse(_ source: String) -> Map {
+ source.utf8.withContiguousStorageIfAvailable { bytes in
+ parseBytes(bytes)
+ } ?? parseBytes(Array(source.utf8))
+}
+
+// Parsers /////////////////////////////////////////////////////////////////////
+
+struct LineParserContext {
+ var components: [Component]
+ var dependencies: [Dependency]
+ var keywordContext: KeywordParserContext
+}
+
+/// Main parsing function, we work on bytes as it's faster.
+private func parseBytes<T: Collection>(_ bytes: T) -> Map where T.Element == UInt8, T.Index == Int {
+ let estimatedLines = bytes.count / 30
+
+ var context = LineParserContext(
+ components: [],
+ dependencies: [],
+ keywordContext: KeywordParserContext(
+ stages: [],
+ notes: [],
+ groups: [],
+ inertias: [],
+ evolutions: []
+ )
+ )
+
+ context.components.reserveCapacity(estimatedLines)
+ context.dependencies.reserveCapacity(estimatedLines)
+ context.keywordContext.notes.reserveCapacity(estimatedLines / 10)
+ context.keywordContext.stages.reserveCapacity(4)
+ context.keywordContext.groups.reserveCapacity(estimatedLines / 20)
+ context.keywordContext.inertias.reserveCapacity(estimatedLines / 20)
+ context.keywordContext.evolutions.reserveCapacity(estimatedLines / 20)
+
+ var index = 0
+ let length = bytes.count
+
+ while index < length {
+ guard let (start, end) = extractLine(bytes, &index, length) else { continue }
+ parseLine(bytes, start, end, &context)
+ }
+
+ return Map(
+ components: context.components,
+ dependencies: context.dependencies,
+ notes: context.keywordContext.notes,
+ stages: context.keywordContext.stages,
+ groups: context.keywordContext.groups,
+ inertias: context.keywordContext.inertias,
+ evolutions: context.keywordContext.evolutions
+ )
+}
+
+/// Given a trimmed line, checks if it's a keyword, dependency or component
+/// and parses it.
+@inline(__always)
+private func parseLine<T: Collection>(
+ _ bytes: T, _ start: Int, _ end: Int,
+ _ context: inout LineParserContext
+) where T.Element == UInt8, T.Index == Int {
+ if bytes[start] == kOpenSquareBracket {
+ parseKeywordLine(bytes, start, end, &context.keywordContext)
+ } else if let separatorIndex = hasDependency(bytes, start, end) {
+ if let dependency = parseDependency(bytes, start, end, separatorIndex) {
+ context.dependencies.append(dependency)
+ }
+ } else if let component = parseComponent(bytes, start, end) {
+ context.components.append(component)
+ }
+}