aboutsummaryrefslogtreecommitdiff
path: root/Map/Business/WmapParser
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-07-09 01:55:50 +0200
committerRuben Beltran del Rio <git@r.bdr.sh>2025-07-09 01:55:50 +0200
commit6926feead1a7fadb7f5f974996bef8c30b3bfdbe (patch)
tree5a16e64f70bea3b9f33220cce5ca524e686dbedf /Map/Business/WmapParser
parentc21a68c807dcb84b4ce910ee0f73238a8019ffe9 (diff)
Use AST based text editor
Diffstat (limited to 'Map/Business/WmapParser')
-rw-r--r--Map/Business/WmapParser/Entity.swift389
-rw-r--r--Map/Business/WmapParser/Lexer.swift344
-rw-r--r--Map/Business/WmapParser/Parser.swift488
-rw-r--r--Map/Business/WmapParser/SourcePosition.swift35
-rw-r--r--Map/Business/WmapParser/Syntax.swift32
-rw-r--r--Map/Business/WmapParser/Token.swift22
-rw-r--r--Map/Business/WmapParser/TokenType.swift36
7 files changed, 1346 insertions, 0 deletions
diff --git a/Map/Business/WmapParser/Entity.swift b/Map/Business/WmapParser/Entity.swift
new file mode 100644
index 0000000..40dd7ca
--- /dev/null
+++ b/Map/Business/WmapParser/Entity.swift
@@ -0,0 +1,389 @@
+struct Wmap {
+ protocol Entity {
+ var position: SourcePosition { get }
+ var range: SourceRange { get }
+ var syntaxElements: [SyntaxElement] { get }
+ }
+
+ struct Vertex: Entity {
+ let label: String
+ let position: SourcePosition
+ let coordinates: (Double, Double)
+ let shape: String?
+ let range: SourceRange
+
+ // Syntax element ranges (populated during parsing)
+ let labelRange: SourceRange
+ let leftParenRange: SourceRange
+ let xCoordRange: SourceRange
+ let commaRange: SourceRange
+ let yCoordRange: SourceRange
+ let rightParenRange: SourceRange
+ let shapeRange: SourceRange?
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // Vertex label
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: labelRange,
+ value: label
+ ))
+
+ // Left parenthesis
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: leftParenRange,
+ value: "("
+ ))
+
+ // X coordinate
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: xCoordRange,
+ value: String(coordinates.0)
+ ))
+
+ // Comma
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: commaRange,
+ value: ","
+ ))
+
+ // Y coordinate
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: yCoordRange,
+ value: String(coordinates.1)
+ ))
+
+ // Right parenthesis
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: rightParenRange,
+ value: ")"
+ ))
+
+ // Shape if present
+ if let shape = shape, let shapeRange = shapeRange {
+ elements.append(
+ SyntaxElement(
+ type: .shape,
+ range: shapeRange,
+ value: "[\(shape)]"
+ ))
+ }
+
+ return elements
+ }
+ }
+
+ struct Edge: Entity {
+ let from: String
+ let to: String
+ let isDirected: Bool
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let fromLabelRange: SourceRange
+ let edgeTypeRange: SourceRange
+ let toLabelRange: SourceRange
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // From vertex label
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: fromLabelRange,
+ value: from
+ ))
+
+ // Edge symbol
+ elements.append(
+ SyntaxElement(
+ type: .symbol,
+ range: edgeTypeRange,
+ value: isDirected ? "->" : "--"
+ ))
+
+ // To vertex label
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: toLabelRange,
+ value: to
+ ))
+
+ return elements
+ }
+ }
+
+ struct Note: Entity {
+ let coordinates: (Double, Double)
+ let text: String
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let keywordRange: SourceRange
+ let leftParenRange: SourceRange
+ let xCoordRange: SourceRange
+ let commaRange: SourceRange
+ let yCoordRange: SourceRange
+ let rightParenRange: SourceRange
+ let textRange: SourceRange
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // [Note] keyword
+ elements.append(
+ SyntaxElement(
+ type: .keyword,
+ range: keywordRange,
+ value: "[Note]"
+ ))
+
+ // Left parenthesis
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: leftParenRange,
+ value: "("
+ ))
+
+ // X coordinate
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: xCoordRange,
+ value: String(coordinates.0)
+ ))
+
+ // Comma
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: commaRange,
+ value: ","
+ ))
+
+ // Y coordinate
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: yCoordRange,
+ value: String(coordinates.1)
+ ))
+
+ // Right parenthesis
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: rightParenRange,
+ value: ")"
+ ))
+
+ // Text content
+ elements.append(
+ SyntaxElement(
+ type: .text,
+ range: textRange,
+ value: text
+ ))
+
+ return elements
+ }
+ }
+
+ struct Stage: Entity {
+ let number: String // i, ii, iii, iv
+ let value: Double
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let leftBracketRange: SourceRange
+ let stageNumberRange: SourceRange
+ let rightBracketRange: SourceRange
+ let valueRange: SourceRange
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // Left bracket
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: leftBracketRange,
+ value: "["
+ ))
+
+ // Stage number
+ elements.append(
+ SyntaxElement(
+ type: .stageNumber,
+ range: stageNumberRange,
+ value: number
+ ))
+
+ // Right bracket
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: rightBracketRange,
+ value: "]"
+ ))
+
+ // Value
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: valueRange,
+ value: String(value)
+ ))
+
+ return elements
+ }
+ }
+
+ struct Group: Entity {
+ let vertices: [String]
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let keywordRange: SourceRange
+ let vertexRanges: [SourceRange]
+ let commaRanges: [SourceRange]
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // [Group] keyword
+ elements.append(
+ SyntaxElement(
+ type: .keyword,
+ range: keywordRange,
+ value: "[Group]"
+ ))
+
+ // Vertex labels and commas
+ for (index, vertex) in vertices.enumerated() {
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: vertexRanges[index],
+ value: vertex
+ ))
+
+ // Add comma if not the last vertex
+ if index < vertices.count - 1 {
+ elements.append(
+ SyntaxElement(
+ type: .punctuation,
+ range: commaRanges[index],
+ value: ","
+ ))
+ }
+ }
+
+ return elements
+ }
+ }
+
+ struct Inertia: Entity {
+ let vertex: String
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let keywordRange: SourceRange
+ let vertexRange: SourceRange
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // [Inertia] keyword
+ elements.append(
+ SyntaxElement(
+ type: .keyword,
+ range: keywordRange,
+ value: "[Inertia]"
+ ))
+
+ // Vertex label
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: vertexRange,
+ value: vertex
+ ))
+
+ return elements
+ }
+ }
+
+ struct Evolution: Entity {
+ let vertex: String
+ let isPositive: Bool
+ let value: Double
+ let position: SourcePosition
+ let range: SourceRange
+
+ // Syntax element ranges
+ let keywordRange: SourceRange
+ let vertexRange: SourceRange
+ let signRange: SourceRange
+ let valueRange: SourceRange
+
+ var syntaxElements: [SyntaxElement] {
+ var elements: [SyntaxElement] = []
+
+ // [Evolution] keyword
+ elements.append(
+ SyntaxElement(
+ type: .keyword,
+ range: keywordRange,
+ value: "[Evolution]"
+ ))
+
+ // Vertex label
+ elements.append(
+ SyntaxElement(
+ type: .vertexLabel,
+ range: vertexRange,
+ value: vertex
+ ))
+
+ // Sign
+ elements.append(
+ SyntaxElement(
+ type: .symbol,
+ range: signRange,
+ value: isPositive ? "+" : "-"
+ ))
+
+ // Value
+ elements.append(
+ SyntaxElement(
+ type: .number,
+ range: valueRange,
+ value: String(value)
+ ))
+
+ return elements
+ }
+ }
+}
diff --git a/Map/Business/WmapParser/Lexer.swift b/Map/Business/WmapParser/Lexer.swift
new file mode 100644
index 0000000..dc789bb
--- /dev/null
+++ b/Map/Business/WmapParser/Lexer.swift
@@ -0,0 +1,344 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+extension Wmap {
+ class Lexer {
+ let input: String
+ private var current: String.Index
+ private var line = 1
+ private var column = 1
+
+ init(_ input: String) {
+ self.input = input
+ self.current = input.startIndex
+ }
+
+ func nextToken() -> Token {
+ skipWhitespace()
+
+ guard current < input.endIndex else {
+ return Token(
+ type: .eof,
+ value: "",
+ position: currentPosition(),
+ stringRange: current..<current
+ )
+ }
+
+ let char = input[current].lowercased().first!
+ let pos = currentPosition()
+ let tokenStart = current
+
+ switch char {
+ case "\n":
+ advance()
+ let token = Token(
+ type: .newline,
+ value: "\n",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ line += 1
+ column = 1
+ return token
+
+ case "(":
+ advance()
+ return Token(
+ type: .leftParen,
+ value: "(",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+
+ case ")":
+ advance()
+ return Token(
+ type: .rightParen,
+ value: ")",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+
+ case "[":
+ return scanBracketedToken()
+
+ case ",":
+ advance()
+ return Token(
+ type: .comma,
+ value: ",",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+
+ case "+":
+ advance()
+ return Token(
+ type: .plus,
+ value: "+",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+
+ case "-":
+ if peek() == "-" {
+ advance()
+ advance()
+ return Token(
+ type: .edgeUndirected,
+ value: "--",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ } else if peek() == ">" {
+ advance()
+ advance()
+ return Token(
+ type: .edgeDirected,
+ value: "->",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ } else {
+ advance()
+ return Token(
+ type: .minus,
+ value: "-",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ case "0"..."9", ".":
+ return scanNumber()
+
+ case "i":
+ return scanStageOrLabel()
+
+ default:
+ // If we encounter an unexpected character, try to scan it as a vertex label
+ // This provides more graceful degradation than failing immediately
+ return scanVertexLabel()
+ }
+ }
+
+ func scanText() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+
+ while current < input.endIndex && input[current] != "\n" {
+ advance()
+ }
+
+ let originalValue = String(input[tokenStart..<current])
+ return Token(
+ type: .text,
+ value: originalValue,
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ private func scanBracketedToken() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+ advance() // consume '['
+
+ let contentStart = current
+ while current < input.endIndex && input[current] != "]" && input[current] != "\n" {
+ advance()
+ }
+
+ // If we hit a newline or EOF before finding the closing bracket, it's an error
+ guard current < input.endIndex && input[current] == "]" else {
+ // Return error token for unclosed bracket (don't consume past newline)
+ return Token(
+ type: .error,
+ value: String(input[tokenStart..<current]),
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ let originalContent = String(input[contentStart..<current])
+ let lowercaseContent = originalContent.lowercased()
+ advance() // consume ']'
+
+ let tokenEnd = current
+
+ switch lowercaseContent {
+ case "note":
+ return Token(
+ type: .noteKeyword,
+ value: "[Note]",
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ case "group":
+ return Token(
+ type: .groupKeyword,
+ value: "[Group]",
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ case "inertia":
+ return Token(
+ type: .inertiaKeyword,
+ value: "[Inertia]",
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ case "evolution":
+ return Token(
+ type: .evolutionKeyword,
+ value: "[Evolution]",
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ case "x", "square", "triangle", "circle":
+ return Token(
+ type: .shapeLabel,
+ value: originalContent, // Use original case, not lowercase
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ default:
+ if lowercaseContent.allSatisfy({ $0 == "i" }) && lowercaseContent.count <= 4 {
+ return Token(
+ type: .stageNumber,
+ value: lowercaseContent,
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ }
+ // Return error token for invalid bracketed content
+ return Token(
+ type: .error,
+ value: String(input[tokenStart..<tokenEnd]),
+ position: pos,
+ stringRange: tokenStart..<tokenEnd
+ )
+ }
+ }
+
+ private func scanNumber() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+
+ // Handle leading decimal point
+ if input[current] == "." {
+ advance()
+ }
+
+ while current < input.endIndex && input[current].isNumber {
+ advance()
+ }
+
+ // Handle decimal point if we haven't seen one
+ if current < input.endIndex && input[current] == "." && input[tokenStart] != "." {
+ advance()
+ while current < input.endIndex && input[current].isNumber {
+ advance()
+ }
+ }
+
+ let value = String(input[tokenStart..<current])
+ return Token(
+ type: .realNumber,
+ value: value,
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ private func scanStageOrLabel() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+
+ // Check if it's a stage number (i, ii, iii, iv)
+ var tempIndex = current
+ var iCount = 0
+
+ while tempIndex < input.endIndex && input[tempIndex].lowercased() == "i" && iCount < 4 {
+ tempIndex = input.index(after: tempIndex)
+ iCount += 1
+ }
+
+ // If we have 1-4 i's and next char is whitespace/delimiter, it's a stage
+ if iCount > 0 && iCount <= 4 && (tempIndex >= input.endIndex || isDelimiter(input[tempIndex]))
+ {
+ current = tempIndex
+ return Token(
+ type: .stageNumber,
+ value: String(repeating: "i", count: iCount),
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ // Otherwise, scan as vertex label
+ return scanVertexLabel()
+ }
+
+ private func scanVertexLabel() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+
+ while current < input.endIndex && !isVertexLabelDelimiter(input[current]) {
+ advance()
+ }
+
+ let originalValue = String(input[tokenStart..<current]).trimmingCharacters(in: .whitespaces)
+ return Token(
+ type: .vertexLabel,
+ value: originalValue,
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ }
+
+ private func isVertexLabelDelimiter(_ char: Character) -> Bool {
+ return char == "-" || char == "+" || char == "," || char == "[" || char == "]" || char == "("
+ || char == ")" || char == "\n"
+ }
+
+ private func isDelimiter(_ char: Character) -> Bool {
+ return char.isWhitespace || char == "\n" || char == "(" || char == ")" || char == "["
+ || char == "]" || char == ","
+ }
+
+ private func skipWhitespace() {
+ while current < input.endIndex && input[current].isWhitespace && input[current] != "\n" {
+ advance()
+ }
+ }
+
+ private func advance() {
+ if current < input.endIndex {
+ current = input.index(after: current)
+ column += 1
+ }
+ }
+
+ private func peek() -> Character? {
+ let next = input.index(after: current)
+ return next < input.endIndex ? input[next] : nil
+ }
+
+ private func currentPosition() -> SourcePosition {
+ return SourcePosition(line: line, column: column, stringIndex: current)
+ }
+ }
+}
diff --git a/Map/Business/WmapParser/Parser.swift b/Map/Business/WmapParser/Parser.swift
new file mode 100644
index 0000000..f04998c
--- /dev/null
+++ b/Map/Business/WmapParser/Parser.swift
@@ -0,0 +1,488 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+import Foundation
+
+extension Wmap {
+ class Parser {
+ private let lexer: Lexer
+ private var currentToken: Token
+ private var vertexLabelMap: [String: String] = [:]
+
+ init(lexer: Lexer) {
+ self.lexer = lexer
+ self.currentToken = lexer.nextToken()
+ }
+
+ func parse() -> [Entity] {
+ var entities: [Entity] = []
+
+ while currentToken.type != .eof {
+ if currentToken.type == .newline {
+ advance()
+ continue
+ }
+
+ do {
+ let entity = try parseEntity()
+ entities.append(entity)
+
+ if currentToken.type == .newline {
+ advance()
+ } else if currentToken.type != .eof {
+ throw ParseError.expectedNewline(actual: currentToken)
+ }
+ } catch {
+ skipToNextLine()
+ }
+ }
+
+ return entities
+ }
+
+ private func parseEntity() throws -> Entity {
+ switch currentToken.type {
+ case .vertexLabel:
+ return try parseVertexOrEdge()
+ case .noteKeyword:
+ return try parseNote()
+ case .stageNumber:
+ return try parseStage()
+ case .groupKeyword:
+ return try parseGroup()
+ case .inertiaKeyword:
+ return try parseInertia()
+ case .evolutionKeyword:
+ return try parseEvolution()
+ case .error:
+ // Error tokens should cause the line to be ignored
+ throw ParseError.invalidLine
+ default:
+ throw ParseError.invalidLine
+ }
+ }
+
+ private func parseVertexOrEdge() throws -> Entity {
+ let startPos = currentToken.position
+ let firstLabel = currentToken.value
+ let firstLabelRange = makeSourceRange(from: currentToken)
+
+ vertexLabelMap[firstLabel.lowercased()] = firstLabel
+ advance()
+
+ if currentToken.type == .edgeUndirected || currentToken.type == .edgeDirected {
+ let isDirected = currentToken.type == .edgeDirected
+ let edgeTypeRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .vertexLabel else {
+ throw ParseError.expectedVertexLabel(actual: currentToken)
+ }
+
+ let secondLabel = currentToken.value
+ let secondLabelRange = makeSourceRange(from: currentToken)
+ let endPos = currentToken.position
+
+ vertexLabelMap[secondLabel.lowercased()] = secondLabel
+ advance()
+
+ return Edge(
+ from: firstLabel,
+ to: secondLabel,
+ isDirected: isDirected,
+ position: startPos,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: firstLabelRange.stringRange
+ .lowerBound..<secondLabelRange.stringRange.upperBound),
+ fromLabelRange: firstLabelRange,
+ edgeTypeRange: edgeTypeRange,
+ toLabelRange: secondLabelRange
+ )
+ } else {
+ // Parse vertex
+ let (coordinates, coordRanges) = try parsePositionWithRanges()
+ let (shape, shapeRange) = try parseOptionalShapeWithRange()
+
+ let endPos = shapeRange?.end ?? coordRanges.rightParen.end
+
+ return Vertex(
+ label: firstLabel,
+ position: startPos,
+ coordinates: coordinates,
+ shape: shape,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: firstLabelRange.stringRange
+ .lowerBound..<(shapeRange?.stringRange.upperBound
+ ?? coordRanges.rightParen.stringRange.upperBound)),
+ labelRange: firstLabelRange,
+ leftParenRange: coordRanges.leftParen,
+ xCoordRange: coordRanges.xCoord,
+ commaRange: coordRanges.comma,
+ yCoordRange: coordRanges.yCoord,
+ rightParenRange: coordRanges.rightParen,
+ shapeRange: shapeRange
+ )
+ }
+ }
+
+ private func parseNote() throws -> Note {
+ let startPos = currentToken.position
+ let keywordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ let (coordinates, coordRanges) = try parsePositionWithRanges()
+
+ // For notes, we need to scan the remaining text on the line
+ let textToken = lexer.scanText()
+ let text = textToken.value
+ let textRange = makeSourceRange(from: textToken)
+ let endPos = textToken.position
+ currentToken = lexer.nextToken() // Update current token after scanning text
+
+ return Note(
+ coordinates: coordinates,
+ text: text,
+ position: startPos,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: keywordRange.stringRange.lowerBound..<textRange.stringRange.upperBound),
+ keywordRange: keywordRange,
+ leftParenRange: coordRanges.leftParen,
+ xCoordRange: coordRanges.xCoord,
+ commaRange: coordRanges.comma,
+ yCoordRange: coordRanges.yCoord,
+ rightParenRange: coordRanges.rightParen,
+ textRange: textRange
+ )
+ }
+
+ private func parseStage() throws -> Stage {
+ let startPos = currentToken.position
+ let stageToken = currentToken
+ let stageValue = stageToken.value
+
+ // For bracketed stage numbers like [ii], we need to parse the components
+ // The lexer gives us the full token, but we need to break it down
+ let tokenRange = stageToken.stringRange
+
+ // Find the bracket positions within the token
+ let leftBracketRange = SourceRange(
+ start: stageToken.position,
+ end: SourcePosition(
+ line: stageToken.position.line,
+ column: stageToken.position.column + 1,
+ stringIndex: lexer.input.index(after: tokenRange.lowerBound)
+ ),
+ stringRange: tokenRange.lowerBound..<lexer.input.index(after: tokenRange.lowerBound)
+ )
+
+ let stageNumberStart = lexer.input.index(after: tokenRange.lowerBound)
+ let stageNumberEnd = lexer.input.index(tokenRange.upperBound, offsetBy: -1)
+ let stageNumberRange = SourceRange(
+ start: SourcePosition(
+ line: stageToken.position.line,
+ column: stageToken.position.column + 1,
+ stringIndex: stageNumberStart
+ ),
+ end: SourcePosition(
+ line: stageToken.position.line,
+ column: stageToken.position.column + 1 + stageValue.count,
+ stringIndex: stageNumberEnd
+ ),
+ stringRange: stageNumberStart..<stageNumberEnd
+ )
+
+ let rightBracketRange = SourceRange(
+ start: SourcePosition(
+ line: stageToken.position.line,
+ column: stageToken.position.column + 1 + stageValue.count,
+ stringIndex: stageNumberEnd
+ ),
+ end: SourcePosition(
+ line: stageToken.position.line,
+ column: stageToken.position.column + 2 + stageValue.count,
+ stringIndex: tokenRange.upperBound
+ ),
+ stringRange: stageNumberEnd..<tokenRange.upperBound
+ )
+
+ advance()
+
+ guard currentToken.type == .realNumber else {
+ throw ParseError.expectedNumber(actual: currentToken)
+ }
+
+ guard let value = Double(currentToken.value) else {
+ throw ParseError.invalidNumber(currentToken.value)
+ }
+
+ let valueRange = makeSourceRange(from: currentToken)
+ let endPos = currentToken.position
+ advance()
+
+ return Stage(
+ number: stageValue,
+ value: value,
+ position: startPos,
+ range: SourceRange(
+ start: startPos,
+ end: endPos,
+ stringRange: leftBracketRange.stringRange.lowerBound..<valueRange.stringRange.upperBound
+ ),
+ leftBracketRange: leftBracketRange,
+ stageNumberRange: stageNumberRange,
+ rightBracketRange: rightBracketRange,
+ valueRange: valueRange
+ )
+ }
+
+ private func parseGroup() throws -> Group {
+ let startPos = currentToken.position
+ let keywordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ var vertices: [String] = []
+ var vertexRanges: [SourceRange] = []
+ var commaRanges: [SourceRange] = []
+
+ guard currentToken.type == .vertexLabel else {
+ throw ParseError.expectedVertexLabel(actual: currentToken)
+ }
+
+ vertices.append(currentToken.value)
+ vertexRanges.append(makeSourceRange(from: currentToken))
+ advance()
+
+ while currentToken.type == .comma {
+ commaRanges.append(makeSourceRange(from: currentToken))
+ advance()
+
+ guard currentToken.type == .vertexLabel else {
+ throw ParseError.expectedVertexLabel(actual: currentToken)
+ }
+
+ vertices.append(currentToken.value)
+ vertexRanges.append(makeSourceRange(from: currentToken))
+ advance()
+ }
+
+ let endPos = vertexRanges.last?.end ?? startPos
+
+ return Group(
+ vertices: vertices,
+ position: startPos,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: keywordRange.stringRange
+ .lowerBound..<vertexRanges.last!.stringRange.upperBound),
+ keywordRange: keywordRange,
+ vertexRanges: vertexRanges,
+ commaRanges: commaRanges
+ )
+ }
+
+ private func parseInertia() throws -> Inertia {
+ let startPos = currentToken.position
+ let keywordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .vertexLabel else {
+ throw ParseError.expectedVertexLabel(actual: currentToken)
+ }
+
+ let vertex = currentToken.value
+ let vertexRange = makeSourceRange(from: currentToken)
+ let endPos = currentToken.position
+ advance()
+
+ return Inertia(
+ vertex: vertex,
+ position: startPos,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: keywordRange.stringRange.lowerBound..<vertexRange.stringRange.upperBound),
+ keywordRange: keywordRange,
+ vertexRange: vertexRange
+ )
+ }
+
+ private func parseEvolution() throws -> Evolution {
+ let startPos = currentToken.position
+ let keywordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .vertexLabel else {
+ throw ParseError.expectedVertexLabel(actual: currentToken)
+ }
+
+ let vertex = currentToken.value
+ let vertexRange = makeSourceRange(from: currentToken)
+ advance()
+
+ let isPositive: Bool
+ let signRange: SourceRange
+ if currentToken.type == .plus {
+ isPositive = true
+ signRange = makeSourceRange(from: currentToken)
+ advance()
+ } else if currentToken.type == .minus {
+ isPositive = false
+ signRange = makeSourceRange(from: currentToken)
+ advance()
+ } else {
+ throw ParseError.expectedSign(actual: currentToken)
+ }
+
+ guard currentToken.type == .realNumber else {
+ throw ParseError.expectedNumber(actual: currentToken)
+ }
+
+ guard let value = Double(currentToken.value) else {
+ throw ParseError.invalidNumber(currentToken.value)
+ }
+
+ let valueRange = makeSourceRange(from: currentToken)
+ let endPos = currentToken.position
+ advance()
+
+ return Evolution(
+ vertex: vertex,
+ isPositive: isPositive,
+ value: value,
+ position: startPos,
+ range: SourceRange(
+ start: startPos, end: endPos,
+ stringRange: keywordRange.stringRange.lowerBound..<valueRange.stringRange.upperBound),
+ keywordRange: keywordRange,
+ vertexRange: vertexRange,
+ signRange: signRange,
+ valueRange: valueRange
+ )
+ }
+
+ // Helper methods
+ private func parsePositionWithRanges() throws -> (
+ (Double, Double),
+ (
+ leftParen: SourceRange, xCoord: SourceRange, comma: SourceRange, yCoord: SourceRange,
+ rightParen: SourceRange
+ )
+ ) {
+ guard currentToken.type == .leftParen else {
+ throw ParseError.expectedLeftParen(actual: currentToken)
+ }
+ let leftParenRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .realNumber else {
+ throw ParseError.expectedNumber(actual: currentToken)
+ }
+ guard let x = Double(currentToken.value) else {
+ throw ParseError.invalidNumber(currentToken.value)
+ }
+ let xCoordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .comma else {
+ throw ParseError.expectedComma(actual: currentToken)
+ }
+ let commaRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .realNumber else {
+ throw ParseError.expectedNumber(actual: currentToken)
+ }
+ guard let y = Double(currentToken.value) else {
+ throw ParseError.invalidNumber(currentToken.value)
+ }
+ let yCoordRange = makeSourceRange(from: currentToken)
+ advance()
+
+ guard currentToken.type == .rightParen else {
+ throw ParseError.expectedRightParen(actual: currentToken)
+ }
+ let rightParenRange = makeSourceRange(from: currentToken)
+ advance()
+
+ return ((x, y), (leftParenRange, xCoordRange, commaRange, yCoordRange, rightParenRange))
+ }
+
+ private func parseOptionalShapeWithRange() throws -> (String?, SourceRange?) {
+ guard currentToken.type == .shapeLabel else {
+ return (nil, nil)
+ }
+
+ let shape = currentToken.value
+ let shapeRange = makeSourceRange(from: currentToken)
+ advance()
+ return (shape, shapeRange)
+ }
+
+ private func makeSourceRange(from token: Token) -> SourceRange {
+ return SourceRange(
+ start: token.position,
+ end: SourcePosition(
+ line: token.position.line,
+ column: token.position.column + token.value.count,
+ stringIndex: token.stringRange.upperBound
+ ),
+ stringRange: token.stringRange
+ )
+ }
+
+ private func advance() {
+ currentToken = lexer.nextToken()
+ }
+
+ private func skipToNextLine() {
+ while currentToken.type != .newline && currentToken.type != .eof {
+ advance()
+ }
+ if currentToken.type == .newline {
+ advance()
+ }
+ }
+ }
+
+ enum ParseError: Error, LocalizedError {
+ case unexpectedToken(expected: String, actual: Token)
+ case expectedVertexLabel(actual: Token)
+ case expectedNumber(actual: Token)
+ case expectedText(actual: Token)
+ case expectedSign(actual: Token)
+ case expectedComma(actual: Token)
+ case expectedLeftParen(actual: Token)
+ case expectedRightParen(actual: Token)
+ case expectedNewline(actual: Token)
+ case invalidNumber(String)
+ case invalidLine
+
+ var errorDescription: String? {
+ switch self {
+ case .unexpectedToken(let expected, let actual):
+ return
+ "Expected \(expected), got \(actual.type) '\(actual.value)' at line \(actual.position.line)"
+ case .expectedVertexLabel(let actual):
+ return "Expected vertex label, got \(actual.type) at line \(actual.position.line)"
+ case .invalidNumber(let value):
+ return "Invalid number format: \(value)"
+ // ... other cases
+ default:
+ return "Parse error"
+ }
+ }
+ }
+}
diff --git a/Map/Business/WmapParser/SourcePosition.swift b/Map/Business/WmapParser/SourcePosition.swift
new file mode 100644
index 0000000..e83c07a
--- /dev/null
+++ b/Map/Business/WmapParser/SourcePosition.swift
@@ -0,0 +1,35 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+extension Wmap {
+ struct SourcePosition: Equatable {
+ let line: Int
+ let column: Int
+ let stringIndex: String.Index
+
+ static func == (lhs: SourcePosition, rhs: SourcePosition) -> Bool {
+ return lhs.line == rhs.line && lhs.column == rhs.column && lhs.stringIndex == rhs.stringIndex
+ }
+ }
+
+ struct SourceRange: Equatable {
+ let start: SourcePosition
+ let end: SourcePosition
+ let stringRange: Range<String.Index>
+
+ static func == (lhs: SourceRange, rhs: SourceRange) -> Bool {
+ return lhs.start == rhs.start && lhs.end == rhs.end && lhs.stringRange == rhs.stringRange
+ }
+ }
+}
diff --git a/Map/Business/WmapParser/Syntax.swift b/Map/Business/WmapParser/Syntax.swift
new file mode 100644
index 0000000..551ebcd
--- /dev/null
+++ b/Map/Business/WmapParser/Syntax.swift
@@ -0,0 +1,32 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+extension Wmap {
+ struct SyntaxElement {
+ let type: SyntaxType
+ let range: SourceRange
+ let value: String
+ }
+
+ enum SyntaxType {
+ case keyword // [Note], [Group], etc.
+ case vertexLabel // vertex names
+ case number // coordinates, values
+ case symbol // ->, --, +, -
+ case shape // [square], [circle]
+ case text // note text
+ case punctuation // (, ), [, ], ,
+ case stageNumber // i, ii, iii, iv
+ }
+}
diff --git a/Map/Business/WmapParser/Token.swift b/Map/Business/WmapParser/Token.swift
new file mode 100644
index 0000000..86bab60
--- /dev/null
+++ b/Map/Business/WmapParser/Token.swift
@@ -0,0 +1,22 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+extension Wmap {
+ struct Token {
+ let type: TokenType
+ let value: String
+ let position: SourcePosition
+ let stringRange: Range<String.Index>
+ }
+}
diff --git a/Map/Business/WmapParser/TokenType.swift b/Map/Business/WmapParser/TokenType.swift
new file mode 100644
index 0000000..2e316bd
--- /dev/null
+++ b/Map/Business/WmapParser/TokenType.swift
@@ -0,0 +1,36 @@
+// Copyright (C) 2024 Rubén Beltrán del Río
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+extension Wmap {
+ enum TokenType: CaseIterable {
+ case vertexLabel
+ case edgeUndirected // --
+ case edgeDirected // ->
+ case leftParen, rightParen
+ case leftBracket, rightBracket
+ case comma
+ case plus, minus
+ case realNumber
+ case stageNumber // i, ii, iii, iv
+ case shapeLabel // x, square, triangle, circle
+ case noteKeyword // [Note]
+ case groupKeyword // [Group]
+ case inertiaKeyword // [Inertia]
+ case evolutionKeyword // [Evolution]
+ case text // free text until newline
+ case newline
+ case eof
+ case error // invalid token that should cause line to be ignored
+ }
+}