aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Documentation/wmap-spec.ebnf17
-rw-r--r--Map/Business/WmapParser/Entity.swift73
-rw-r--r--Map/Business/WmapParser/Lexer.swift93
-rw-r--r--Map/Business/WmapParser/Parser.swift87
-rw-r--r--Map/Data/MapDocument.swift2
-rw-r--r--Map/Presentation/Complex Components/MapRender/MapRenderView.swift152
-rw-r--r--Map/Presentation/MapEditor.swift5
7 files changed, 339 insertions, 90 deletions
diff --git a/Documentation/wmap-spec.ebnf b/Documentation/wmap-spec.ebnf
index d3c5ad7..05eabe5 100644
--- a/Documentation/wmap-spec.ebnf
+++ b/Documentation/wmap-spec.ebnf
@@ -2,7 +2,7 @@
(* Top-level structure *)
program = { line } ;
-line = ( entity newline ) | ( ignored_line newline ) | newline ;
+line = ( entity line_ending ) | ( ignored_line line_ending ) | line_ending ;
entity = vertex | edge | note | stage | group | inertia | evolution ;
ignored_line = ? any sequence of tokens that doesn't match entity grammar ? ;
@@ -23,8 +23,8 @@ edge_type = "--" | "->" ;
sign = "+" | "-" ;
(* Terminal symbols *)
-vertex_label = preserved_case( { character - ( "-" | "+" | "," | "[" | "]" | "(" | ")" | newline ) } ) ;
-text = preserved_case( { character - newline } ) ;
+vertex_label = preserved_case( { character - ( "-" | "+" | "," | "[" | "]" | "(" | ")" | line_ending ) } ) ;
+text = preserved_case( { character - line_ending } ) ;
real_number = [ digit ] { digit } [ "." { digit } ] | "." digit { digit } ;
stage_number = case_insensitive( "i" | "ii" | "iii" | "iv" ) ;
shape_label = case_insensitive( "x" | "square" | "triangle" | "circle" ) ;
@@ -32,7 +32,10 @@ shape_label = case_insensitive( "x" | "square" | "triangle" | "circle" ) ;
(* Lexical elements *)
character = ? any Unicode character ? ;
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
-newline = ? newline character ? ;
+line_ending = lf | crlf | cr ;
+lf = ? U+000A (Line Feed) ? ;
+cr = ? U+000D (Carriage Return) ? ;
+crlf = cr lf ;
(* Lexical rules *)
case_insensitive(x) = ? case-insensitive match of x ? ;
@@ -42,6 +45,8 @@ preserved_case(x) = ? case-insensitive match of x, preserving original case for
(* - Keywords and shape labels are case-insensitive *)
(* - Vertex labels are case-insensitive for matching but preserve original case *)
(* - Text content preserves original case *)
-(* - Whitespace (except newlines) may appear between any tokens *)
+(* - Whitespace (except line endings) may appear between any tokens *)
(* - Each line contains exactly one entity *)
-(* - Invalid lines should be ignored. *)
+(* - Invalid lines should be ignored *)
+(* - Line endings support Unix (LF), Windows (CRLF), and classic Mac (CR) formats *)
+(* - CRLF is treated as a single line ending, not separate CR and LF *)
diff --git a/Map/Business/WmapParser/Entity.swift b/Map/Business/WmapParser/Entity.swift
index 40dd7ca..52f6e93 100644
--- a/Map/Business/WmapParser/Entity.swift
+++ b/Map/Business/WmapParser/Entity.swift
@@ -1,11 +1,51 @@
struct Wmap {
- protocol Entity {
- var position: SourcePosition { get }
- var range: SourceRange { get }
- var syntaxElements: [SyntaxElement] { get }
+ enum Entity: Equatable {
+ case vertex(Vertex)
+ case edge(Edge)
+ case note(Note)
+ case stage(Stage)
+ case group(Group)
+ case inertia(Inertia)
+ case evolution(Evolution)
+
+ var position: SourcePosition {
+ switch self {
+ case .vertex(let vertex): return vertex.position
+ case .edge(let edge): return edge.position
+ case .note(let note): return note.position
+ case .stage(let stage): return stage.position
+ case .group(let group): return group.position
+ case .inertia(let inertia): return inertia.position
+ case .evolution(let evolution): return evolution.position
+ }
+ }
+
+ var range: SourceRange {
+ switch self {
+ case .vertex(let vertex): return vertex.range
+ case .edge(let edge): return edge.range
+ case .note(let note): return note.range
+ case .stage(let stage): return stage.range
+ case .group(let group): return group.range
+ case .inertia(let inertia): return inertia.range
+ case .evolution(let evolution): return evolution.range
+ }
+ }
+
+ var syntaxElements: [SyntaxElement] {
+ switch self {
+ case .vertex(let vertex): return vertex.syntaxElements
+ case .edge(let edge): return edge.syntaxElements
+ case .note(let note): return note.syntaxElements
+ case .stage(let stage): return stage.syntaxElements
+ case .group(let group): return group.syntaxElements
+ case .inertia(let inertia): return inertia.syntaxElements
+ case .evolution(let evolution): return evolution.syntaxElements
+ }
+ }
}
- struct Vertex: Entity {
+ struct Vertex: Equatable {
let label: String
let position: SourcePosition
let coordinates: (Double, Double)
@@ -21,6 +61,12 @@ struct Wmap {
let rightParenRange: SourceRange
let shapeRange: SourceRange?
+ static func == (lhs: Vertex, rhs: Vertex) -> Bool {
+ return lhs.label == rhs.label && lhs.position == rhs.position
+ && lhs.coordinates.0 == rhs.coordinates.0 && lhs.coordinates.1 == rhs.coordinates.1
+ && lhs.shape == rhs.shape && lhs.range == rhs.range
+ }
+
var syntaxElements: [SyntaxElement] {
var elements: [SyntaxElement] = []
@@ -86,7 +132,7 @@ struct Wmap {
}
}
- struct Edge: Entity {
+ struct Edge: Equatable {
let from: String
let to: String
let isDirected: Bool
@@ -129,7 +175,7 @@ struct Wmap {
}
}
- struct Note: Entity {
+ struct Note: Equatable {
let coordinates: (Double, Double)
let text: String
let position: SourcePosition
@@ -144,6 +190,11 @@ struct Wmap {
let rightParenRange: SourceRange
let textRange: SourceRange
+ static func == (lhs: Note, rhs: Note) -> Bool {
+ return lhs.coordinates.0 == rhs.coordinates.0 && lhs.coordinates.1 == rhs.coordinates.1
+ && lhs.text == rhs.text && lhs.position == rhs.position && lhs.range == rhs.range
+ }
+
var syntaxElements: [SyntaxElement] {
var elements: [SyntaxElement] = []
@@ -207,7 +258,7 @@ struct Wmap {
}
}
- struct Stage: Entity {
+ struct Stage: Equatable {
let number: String // i, ii, iii, iv
let value: Double
let position: SourcePosition
@@ -258,7 +309,7 @@ struct Wmap {
}
}
- struct Group: Entity {
+ struct Group: Equatable {
let vertices: [String]
let position: SourcePosition
let range: SourceRange
@@ -303,7 +354,7 @@ struct Wmap {
}
}
- struct Inertia: Entity {
+ struct Inertia: Equatable {
let vertex: String
let position: SourcePosition
let range: SourceRange
@@ -335,7 +386,7 @@ struct Wmap {
}
}
- struct Evolution: Entity {
+ struct Evolution: Equatable {
let vertex: String
let isPositive: Bool
let value: Double
diff --git a/Map/Business/WmapParser/Lexer.swift b/Map/Business/WmapParser/Lexer.swift
index dc789bb..c79879f 100644
--- a/Map/Business/WmapParser/Lexer.swift
+++ b/Map/Business/WmapParser/Lexer.swift
@@ -18,6 +18,7 @@ extension Wmap {
private var current: String.Index
private var line = 1
private var column = 1
+ private var expectingText = false
init(_ input: String) {
self.input = input
@@ -41,17 +42,9 @@ extension Wmap {
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 "\n", "\r":
+ expectingText = false // Reset expectingText on newline
+ return scanLineEnding()
case "(":
advance()
@@ -128,6 +121,12 @@ extension Wmap {
return scanStageOrLabel()
default:
+ // If we're expecting text, scan as text instead of vertex label
+ if expectingText {
+ expectingText = false // Reset after scanning text
+ return scanText()
+ }
+
// If we encounter an unexpected character, try to scan it as a vertex label
// This provides more graceful degradation than failing immediately
return scanVertexLabel()
@@ -136,13 +135,17 @@ extension Wmap {
func scanText() -> Token {
let pos = currentPosition()
+
+ // Skip any leading whitespace
+ skipWhitespace()
+
let tokenStart = current
- while current < input.endIndex && input[current] != "\n" {
+ while current < input.endIndex && !isLineEnding(input[current]) {
advance()
}
- let originalValue = String(input[tokenStart..<current])
+ let originalValue = String(input[tokenStart..<current]).trimmingCharacters(in: .whitespaces)
return Token(
type: .text,
value: originalValue,
@@ -151,19 +154,69 @@ extension Wmap {
)
}
+ private func scanLineEnding() -> Token {
+ let pos = currentPosition()
+ let tokenStart = current
+
+ if input[current] == "\r" {
+ advance()
+ // Check for CRLF
+ if current < input.endIndex && input[current] == "\n" {
+ advance()
+ let token = Token(
+ type: .newline,
+ value: "\r\n",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ line += 1
+ column = 1
+ return token
+ } else {
+ // Just CR
+ let token = Token(
+ type: .newline,
+ value: "\r",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ line += 1
+ column = 1
+ return token
+ }
+ } else if input[current] == "\n" {
+ advance()
+ let token = Token(
+ type: .newline,
+ value: "\n",
+ position: pos,
+ stringRange: tokenStart..<current
+ )
+ line += 1
+ column = 1
+ return token
+ } else {
+ fatalError("scanLineEnding called on non-line-ending character")
+ }
+ }
+
+ private func isLineEnding(_ char: Character) -> Bool {
+ return char == "\n" || char == "\r"
+ }
+
private func scanBracketedToken() -> Token {
let pos = currentPosition()
let tokenStart = current
advance() // consume '['
let contentStart = current
- while current < input.endIndex && input[current] != "]" && input[current] != "\n" {
+ while current < input.endIndex && input[current] != "]" && !isLineEnding(input[current]) {
advance()
}
- // If we hit a newline or EOF before finding the closing bracket, it's an error
+ // If we hit a line ending 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 error token for unclosed bracket (don't consume past line ending)
return Token(
type: .error,
value: String(input[tokenStart..<current]),
@@ -180,6 +233,7 @@ extension Wmap {
switch lowercaseContent {
case "note":
+ expectingText = true // Set expectingText when we return a note keyword
return Token(
type: .noteKeyword,
value: "[Note]",
@@ -311,16 +365,17 @@ extension Wmap {
private func isVertexLabelDelimiter(_ char: Character) -> Bool {
return char == "-" || char == "+" || char == "," || char == "[" || char == "]" || char == "("
- || char == ")" || char == "\n"
+ || char == ")" || isLineEnding(char)
}
private func isDelimiter(_ char: Character) -> Bool {
- return char.isWhitespace || char == "\n" || char == "(" || char == ")" || char == "["
+ return char.isWhitespace || isLineEnding(char) || char == "(" || char == ")" || char == "["
|| char == "]" || char == ","
}
private func skipWhitespace() {
- while current < input.endIndex && input[current].isWhitespace && input[current] != "\n" {
+ while current < input.endIndex && input[current].isWhitespace && !isLineEnding(input[current])
+ {
advance()
}
}
diff --git a/Map/Business/WmapParser/Parser.swift b/Map/Business/WmapParser/Parser.swift
index f04998c..9418263 100644
--- a/Map/Business/WmapParser/Parser.swift
+++ b/Map/Business/WmapParser/Parser.swift
@@ -56,15 +56,15 @@ extension Wmap {
case .vertexLabel:
return try parseVertexOrEdge()
case .noteKeyword:
- return try parseNote()
+ return .note(try parseNote())
case .stageNumber:
- return try parseStage()
+ return .stage(try parseStage())
case .groupKeyword:
- return try parseGroup()
+ return .group(try parseGroup())
case .inertiaKeyword:
- return try parseInertia()
+ return .inertia(try parseInertia())
case .evolutionKeyword:
- return try parseEvolution()
+ return .evolution(try parseEvolution())
case .error:
// Error tokens should cause the line to be ignored
throw ParseError.invalidLine
@@ -97,19 +97,20 @@ extension Wmap {
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
- )
+ return .edge(
+ 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()
@@ -117,24 +118,25 @@ extension Wmap {
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
- )
+ return .vertex(
+ 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
+ ))
}
}
@@ -145,12 +147,13 @@ extension Wmap {
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
+ guard currentToken.type == .text else {
+ throw ParseError.expectedText(actual: currentToken)
+ }
+ let text = currentToken.value
+ let textRange = makeSourceRange(from: currentToken)
+ let endPos = currentToken.position
+ advance()
return Note(
coordinates: coordinates,
diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift
index 95fcfde..6cc84d7 100644
--- a/Map/Data/MapDocument.swift
+++ b/Map/Data/MapDocument.swift
@@ -69,7 +69,7 @@ struct MapDocument: FileDocument {
@MainActor
func exportAsImage(withEvolution selectedEvolution: StageType) -> NSImage? {
let renderView = MapRenderView(
- document: .constant(self),
+ entities: self.parsed,
evolution: .constant(selectedEvolution))
let renderer = ImageRenderer(content: renderView)
diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
index 72a765d..08d1d61 100644
--- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
+++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
@@ -19,8 +19,10 @@ import SwiftUI
struct MapRenderView: View {
- @Binding var document: MapDocument
+ var entities: [Wmap.Entity]
@Binding var evolution: StageType
+
+ @State private var lastEntityHash: Int = 0
@AppStorage("showMapBackground") var showMapBackground: Bool = UserPreferences.Defaults
.showMapBackground
@AppStorage("useCustomFont") var useCustomFont: Bool = UserPreferences.Defaults.useCustomFont
@@ -44,14 +46,14 @@ struct MapRenderView: View {
var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in }
- private func parseMapInBackground() {
+ private func convertEntitiesInBackground() {
parseTask?.cancel()
- let currentText = document.text
+ let currentEntities = entities
parseTask = Task {
let parsed = await Task.detached(priority: .userInitiated) {
- MapParser.parse(content: currentText)
+ convertEntitiesToParsedMap(currentEntities)
}.value
let positions = await Task.detached(priority: .userInitiated) {
@@ -92,6 +94,138 @@ struct MapRenderView: View {
}
}
+ private nonisolated func convertEntitiesToParsedMap(_ entities: [Wmap.Entity]) -> ParsedMap {
+ var vertices: [Vertex] = []
+ var edges: [MapEdge] = []
+ var inertias: [Inertia] = []
+ var opportunities: [Opportunity] = []
+ var notes: [Note] = []
+ var stages: [CGFloat] = [25.0, 50.0, 75.0] // default dimensions
+ var groups: [[Vertex]] = []
+
+ // Build a vertex lookup for edges, groups, etc.
+ var vertexMap: [String: Vertex] = [:]
+ var vertexIdCounter = 0
+
+ for entity in entities {
+ switch entity {
+ case .vertex(let vertex):
+ let newVertex = Vertex(
+ id: vertexIdCounter,
+ label: vertex.label,
+ position: CGPoint(x: vertex.coordinates.0, y: vertex.coordinates.1),
+ shape: convertShape(vertex.shape)
+ )
+ vertices.append(newVertex)
+ vertexMap[vertex.label] = newVertex
+ vertexIdCounter += 1
+
+ case .note(let note):
+ notes.append(
+ Note(
+ id: notes.count,
+ position: CGPoint(x: note.coordinates.0, y: note.coordinates.1),
+ text: note.text
+ ))
+
+ case .stage(let stage):
+ let stageIndex = convertStageNumber(stage.number) - 1
+ if stageIndex >= 0 && stageIndex < 3 {
+ stages[stageIndex] = stage.value
+ }
+
+ default:
+ continue
+ }
+ }
+
+ // Second pass for entities that depend on vertices
+ for entity in entities {
+ switch entity {
+ case .edge(let edge):
+ if let fromVertex = vertexMap[edge.from],
+ let toVertex = vertexMap[edge.to]
+ {
+ edges.append(
+ MapEdge(
+ id: edges.count,
+ origin: fromVertex.position,
+ destination: toVertex.position,
+ arrowhead: edge.isDirected
+ ))
+ }
+
+ case .inertia(let inertia):
+ if let vertex = vertexMap[inertia.vertex] {
+ inertias.append(
+ Inertia(
+ id: inertias.count,
+ position: vertex.position
+ ))
+ }
+
+ case .evolution(let evolution):
+ if let vertex = vertexMap[evolution.vertex] {
+ let direction: CGFloat = evolution.isPositive ? 1.0 : -1.0
+ let destination = CGPoint(
+ x: vertex.position.x + (direction * evolution.value),
+ y: vertex.position.y
+ )
+ opportunities.append(
+ Opportunity(
+ id: opportunities.count,
+ origin: vertex.position,
+ destination: destination
+ ))
+ }
+
+ case .group(let group):
+ var groupVertices: [Vertex] = []
+ for vertexName in group.vertices {
+ if let vertex = vertexMap[vertexName] {
+ groupVertices.append(vertex)
+ }
+ }
+ if !groupVertices.isEmpty {
+ groups.append(groupVertices)
+ }
+
+ default:
+ continue
+ }
+ }
+
+ return ParsedMap(
+ vertices: vertices,
+ edges: edges,
+ inertias: inertias,
+ opportunities: opportunities,
+ notes: notes,
+ stages: stages,
+ groups: groups
+ )
+ }
+
+ private nonisolated func convertShape(_ shape: String?) -> VertexShape {
+ guard let shape = shape?.lowercased() else { return .circle }
+ switch shape {
+ case "square": return .square
+ case "triangle": return .triangle
+ case "x": return .x
+ default: return .circle
+ }
+ }
+
+ private nonisolated func convertStageNumber(_ stageNumber: String) -> Int {
+ switch stageNumber.lowercased() {
+ case "i": return 1
+ case "ii": return 2
+ case "iii": return 3
+ case "iv": return 4
+ default: return 1
+ }
+ }
+
var body: some View {
ZStack(alignment: .topLeading) {
@@ -137,13 +271,13 @@ struct MapRenderView: View {
height: mapSize.height + 2 * padding, alignment: .topLeading
)
.onAppear {
- parseMapInBackground()
+ convertEntitiesInBackground()
}
- .onChange(of: document.text) {
- parseMapInBackground()
+ .onChange(of: entities) { _, _ in
+ convertEntitiesInBackground()
}
.onChange(of: useSmartLabelPositioning) {
- parseMapInBackground()
+ convertEntitiesInBackground()
}
.onDisappear {
parseTask?.cancel()
@@ -161,7 +295,7 @@ struct MapRenderView: View {
#Preview {
MapRenderView(
- document: Binding.constant(MapDocument(text: "")),
+ entities: [],
evolution: Binding.constant(StageType.general)
)
}
diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift
index 167ef77..0f8f24e 100644
--- a/Map/Presentation/MapEditor.swift
+++ b/Map/Presentation/MapEditor.swift
@@ -120,8 +120,9 @@ struct MapEditor: View {
GeometryReader { geometry in
ScrollView([.horizontal, .vertical], showsIndicators: false) {
MapRenderView(
- document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex
- ).scaleEffect(zoom, anchor: .center).frame(
+ entities: parsedMap, evolution: $selectedEvolution, onDragVertex: onDragVertex
+ )
+ .scaleEffect(zoom, anchor: .center).frame(
width: (Dimensions.Map.size.width + 2 * Dimensions.Map.padding) * zoom,
height: (Dimensions.Map.size.height + 2 * Dimensions.Map.padding) * zoom
)