├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── nfldb.er └── simple.er └── src ├── ast.rs ├── main.rs ├── parser.rs └── render.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # will have compiled files and executables 2 | /target/ 3 | 4 | # These are backup files generated by rustfmt 5 | **/*.rs.bk 6 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "ansi_term" 5 | version = "0.12.1" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 8 | dependencies = [ 9 | "winapi", 10 | ] 11 | 12 | [[package]] 13 | name = "arrayvec" 14 | version = "0.5.2" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 17 | 18 | [[package]] 19 | name = "bitflags" 20 | version = "1.2.1" 21 | source = "registry+https://github.com/rust-lang/crates.io-index" 22 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 23 | 24 | [[package]] 25 | name = "bitvec" 26 | version = "0.19.5" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" 29 | dependencies = [ 30 | "funty", 31 | "radium", 32 | "tap", 33 | "wyz", 34 | ] 35 | 36 | [[package]] 37 | name = "cfg-if" 38 | version = "1.0.0" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 41 | 42 | [[package]] 43 | name = "ctor" 44 | version = "0.1.20" 45 | source = "registry+https://github.com/rust-lang/crates.io-index" 46 | checksum = "5e98e2ad1a782e33928b96fc3948e7c355e5af34ba4de7670fe8bac2a3b2006d" 47 | dependencies = [ 48 | "quote", 49 | "syn", 50 | ] 51 | 52 | [[package]] 53 | name = "diff" 54 | version = "0.1.12" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" 57 | 58 | [[package]] 59 | name = "erd" 60 | version = "0.1.0" 61 | dependencies = [ 62 | "getopts", 63 | "maplit", 64 | "nom", 65 | "pretty_assertions", 66 | ] 67 | 68 | [[package]] 69 | name = "funty" 70 | version = "1.1.0" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" 73 | 74 | [[package]] 75 | name = "getopts" 76 | version = "0.2.21" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" 79 | dependencies = [ 80 | "unicode-width", 81 | ] 82 | 83 | [[package]] 84 | name = "lexical-core" 85 | version = "0.7.5" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374" 88 | dependencies = [ 89 | "arrayvec", 90 | "bitflags", 91 | "cfg-if", 92 | "ryu", 93 | "static_assertions", 94 | ] 95 | 96 | [[package]] 97 | name = "maplit" 98 | version = "1.0.2" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 101 | 102 | [[package]] 103 | name = "memchr" 104 | version = "2.3.4" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 107 | 108 | [[package]] 109 | name = "nom" 110 | version = "6.1.2" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" 113 | dependencies = [ 114 | "bitvec", 115 | "funty", 116 | "lexical-core", 117 | "memchr", 118 | "version_check", 119 | ] 120 | 121 | [[package]] 122 | name = "output_vt100" 123 | version = "0.1.2" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9" 126 | dependencies = [ 127 | "winapi", 128 | ] 129 | 130 | [[package]] 131 | name = "pretty_assertions" 132 | version = "0.7.2" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "1cab0e7c02cf376875e9335e0ba1da535775beb5450d21e1dffca068818ed98b" 135 | dependencies = [ 136 | "ansi_term", 137 | "ctor", 138 | "diff", 139 | "output_vt100", 140 | ] 141 | 142 | [[package]] 143 | name = "proc-macro2" 144 | version = "1.0.26" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" 147 | dependencies = [ 148 | "unicode-xid", 149 | ] 150 | 151 | [[package]] 152 | name = "quote" 153 | version = "1.0.9" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 156 | dependencies = [ 157 | "proc-macro2", 158 | ] 159 | 160 | [[package]] 161 | name = "radium" 162 | version = "0.5.3" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" 165 | 166 | [[package]] 167 | name = "ryu" 168 | version = "1.0.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 171 | 172 | [[package]] 173 | name = "static_assertions" 174 | version = "1.1.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 177 | 178 | [[package]] 179 | name = "syn" 180 | version = "1.0.71" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "ad184cc9470f9117b2ac6817bfe297307418819ba40552f9b3846f05c33d5373" 183 | dependencies = [ 184 | "proc-macro2", 185 | "quote", 186 | "unicode-xid", 187 | ] 188 | 189 | [[package]] 190 | name = "tap" 191 | version = "1.0.1" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 194 | 195 | [[package]] 196 | name = "unicode-width" 197 | version = "0.1.8" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 200 | 201 | [[package]] 202 | name = "unicode-xid" 203 | version = "0.2.2" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 206 | 207 | [[package]] 208 | name = "version_check" 209 | version = "0.9.3" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 212 | 213 | [[package]] 214 | name = "winapi" 215 | version = "0.3.9" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 218 | dependencies = [ 219 | "winapi-i686-pc-windows-gnu", 220 | "winapi-x86_64-pc-windows-gnu", 221 | ] 222 | 223 | [[package]] 224 | name = "winapi-i686-pc-windows-gnu" 225 | version = "0.4.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 228 | 229 | [[package]] 230 | name = "winapi-x86_64-pc-windows-gnu" 231 | version = "0.4.0" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 234 | 235 | [[package]] 236 | name = "wyz" 237 | version = "0.2.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" 240 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "erd" 3 | version = "0.1.0" 4 | authors = ["Dave Challis "] 5 | edition = "2018" 6 | publish = false 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | nom = { version = "6.1", features = ["alloc"] } 12 | getopts = "0.2" 13 | 14 | [dev-dependencies] 15 | maplit = "1.0" 16 | pretty_assertions = "0.7" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dave Challis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # erd-rs 2 | 3 | Rust CLI tool for creating entity-relationship diagrams from plain text markup. 4 | Based on [erd](https://github.com/BurntSushi/erd) (uses the same input format 5 | and output rendering). 6 | 7 | Entities, relationships and attributes are defined in a simple plain text 8 | markup format, which is used to generate an entity-relationship diagram in 9 | [DOT](https://en.wikipedia.org/wiki/DOT_(graph_description_language)) format. 10 | 11 | This can then be rendered into e.g. pdf, png, svg, etc. using 12 | [Graphviz](https://graphviz.org/) or another similar tool. 13 | 14 | ## Status 15 | 16 | Currently under development, general parsing and mostly default output is 17 | completed, but not yet feature complete. 18 | 19 | Approximate TODO: 20 | 21 | * rendering customisation 22 | * checks that entities exist when parsing relationships 23 | * code cleanup/refactoring 24 | * internal function/parser documentation 25 | * user guide/overvie(is implemented, but rendering is not)w 26 | * additional error handling 27 | * add github actions to run tests 28 | * add action to build/push docker image to docker hub 29 | -------------------------------------------------------------------------------- /examples/nfldb.er: -------------------------------------------------------------------------------- 1 | title {label: "nfldb Entity-Relationship diagram (condensed)", size: "20"} 2 | 3 | # Nice colors from Erwiz: 4 | # red #fcecec 5 | # blue #ececfc 6 | # green #d0e0d0 7 | # yellow #fbfbdb 8 | # orange #eee0a0 9 | 10 | # Entities 11 | 12 | [player] {bgcolor: "#d0e0d0"} 13 | *player_id {label: "varchar, not null"} 14 | full_name {label: "varchar, null"} 15 | team {label: "varchar, not null"} 16 | position {label: "player_pos, not null"} 17 | status {label: "player_status, not null"} 18 | 19 | [team] {bgcolor: "#d0e0d0"} 20 | *team_id {label: "varchar, not null"} 21 | city {label: "varchar, not null"} 22 | name {label: "varchar, not null"} 23 | 24 | [game] {bgcolor: "#ececfc"} 25 | *gsis_id {label: "gameid, not null"} 26 | start_time {label: "utctime, not null"} 27 | week {label: "usmallint, not null"} 28 | season_year {label: "usmallint, not null"} 29 | season_type {label: "season_phase, not null"} 30 | finished {label: "boolean, not null"} 31 | home_team {label: "varchar, not null"} 32 | home_score {label: "usmallint, not null"} 33 | away_team {label: "varchar, not null"} 34 | away_score {label: "usmallint, not null"} 35 | 36 | [drive] {bgcolor: "#ececfc"} 37 | *+gsis_id {label: "gameid, not null"} 38 | *drive_id {label: "usmallint, not null"} 39 | start_field {label: "field_pos, null"} 40 | start_time {label: "game_time, not null"} 41 | end_field {label: "field_pos, null"} 42 | end_time {label: "game_time, not null"} 43 | pos_team {label: "varchar, not null"} 44 | pos_time {label: "pos_period, null"} 45 | 46 | [play] {bgcolor: "#ececfc"} 47 | *+gsis_id {label: "gameid, not null"} 48 | *+drive_id {label: "usmallint, not null"} 49 | *play_id {label: "usmallint, not null"} 50 | time {label: "game_time, not null"} 51 | pos_team {label: "varchar, not null"} 52 | yardline {label: "field_pos, null"} 53 | down {label: "smallint, null"} 54 | yards_to_go {label: "smallint, null"} 55 | 56 | [play_player] {bgcolor: "#ececfc"} 57 | *+gsis_id {label: "gameid, not null"} 58 | *+drive_id {label: "usmallint, not null"} 59 | *+play_id {label: "usmallint, not null"} 60 | *+player_id {label: "varchar, not null"} 61 | team {label: "varchar, not null"} 62 | 63 | [meta] {bgcolor: "#fcecec"} 64 | version {label: "smallint, null"} 65 | season_type {label: "season_phase, null"} 66 | season_year {label: "usmallint, null"} 67 | week {label: "usmallint, null"} 68 | 69 | # Relationships 70 | 71 | player *--1 team 72 | game *--1 team {label: "home"} 73 | game *--1 team {label: "away"} 74 | drive *--1 team 75 | play *--1 team 76 | play_player *--1 team 77 | 78 | game 1--* drive 79 | game 1--* play 80 | game 1--* play_player 81 | 82 | drive 1--* play 83 | drive 1--* play_player 84 | 85 | play 1--* play_player 86 | 87 | player 1--* play_player 88 | 89 | -------------------------------------------------------------------------------- /examples/simple.er: -------------------------------------------------------------------------------- 1 | # Entities are declared in '[' ... ']'. All attributes after the entity header 2 | # up until the end of the file (or the next entity declaration) correspond 3 | # to this entity. 4 | [Person] 5 | *name 6 | height 7 | weight 8 | `birth date` 9 | +birth_place_id 10 | 11 | [`Birth Place`] 12 | *id 13 | `birth city` 14 | 'birth state' 15 | "birth country" 16 | 17 | # Each relationship must be between exactly two entities, which need not 18 | # be distinct. Each entity in the relationship has exactly one of four 19 | # possible cardinalities: 20 | # 21 | # Cardinality Syntax 22 | # 0 or 1 ? 23 | # exactly 1 1 24 | # 0 or more * 25 | # 1 or more + 26 | Person *--1 `Birth Place` 27 | -------------------------------------------------------------------------------- /src/ast.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::collections::HashMap; 3 | 4 | pub const OPT_COLOR: &str = "color"; 5 | pub const OPT_LABEL: &str = "label"; 6 | pub const OPT_SIZE: &str = "size"; 7 | pub const OPT_FONT: &str = "font"; 8 | pub const OPT_BACKGROUND_COLOR: &str = "bgcolor"; 9 | pub const OPT_BORDER_COLOR: &str = "border-color"; 10 | pub const OPT_BORDER: &str = "border"; 11 | 12 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 13 | pub struct Erd { 14 | pub entities: Vec, 15 | pub relationships: Vec, 16 | pub title_options: TitleOptions, 17 | } 18 | 19 | #[derive(Debug, Clone, PartialEq, Eq)] 20 | pub enum Ast { 21 | Entity(Entity), 22 | Attribute(Attribute), 23 | Relation(Relation), 24 | GlobalOption(GlobalOption), 25 | } 26 | 27 | #[derive(Clone, Debug, Eq, PartialEq)] 28 | pub struct Entity { 29 | pub name: String, 30 | pub attribs: Vec, 31 | pub options: EntityOptions, 32 | pub header_options: HeaderOptions, 33 | } 34 | 35 | impl Entity { 36 | pub fn add_attribute(&mut self, attr: Attribute) { 37 | self.attribs.push(attr) 38 | } 39 | } 40 | 41 | #[derive(Clone, Default, Debug, Eq, PartialEq)] 42 | pub struct Attribute { 43 | pub field: String, 44 | pub pk: bool, 45 | pub fk: bool, 46 | pub options: AttributeOptions, 47 | } 48 | 49 | impl Attribute { 50 | pub fn with_field>(field: S) -> Self { 51 | Self { 52 | field: field.into(), 53 | pk: false, 54 | fk: false, 55 | options: AttributeOptions::default(), 56 | } 57 | } 58 | } 59 | 60 | #[derive(Clone, Debug, Eq, PartialEq)] 61 | pub struct Relation { 62 | pub entity1: String, 63 | pub entity2: String, 64 | pub card1: Cardinality, 65 | pub card2: Cardinality, 66 | pub options: RelationshipOptions, 67 | } 68 | 69 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 70 | pub enum Cardinality { 71 | ZeroOne, 72 | One, 73 | ZeroPlus, 74 | OnePlus, 75 | } 76 | 77 | impl fmt::Display for Cardinality { 78 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 79 | match self { 80 | Cardinality::ZeroOne => write!(f, "{{0,1}}"), 81 | Cardinality::One => write!(f, "1"), 82 | Cardinality::ZeroPlus => write!(f, "0..N"), 83 | Cardinality::OnePlus => write!(f, "1..N"), 84 | } 85 | } 86 | } 87 | 88 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 89 | pub enum GlobalOptionType { 90 | Title, 91 | Header, 92 | Entity, 93 | Relationship, 94 | } 95 | 96 | #[derive(Clone, Debug, Eq, PartialEq)] 97 | pub struct GlobalOption { 98 | pub option_type: GlobalOptionType, 99 | pub options: HashMap, 100 | } 101 | 102 | #[derive(Clone, Debug, Eq, PartialEq)] 103 | pub struct TitleOptions { 104 | pub size: u8, 105 | pub label: Option, 106 | pub color: Option, 107 | pub font: Option, 108 | } 109 | 110 | impl TitleOptions { 111 | pub fn merge_hashmap(&mut self, m: &HashMap) -> Result<(), String> { 112 | for (k, v) in m { 113 | match k.as_str() { 114 | OPT_LABEL => self.label = Some(v.clone()), 115 | OPT_COLOR => self.color = Some(v.clone()), 116 | OPT_FONT => self.font = Some(v.clone()), 117 | OPT_SIZE => self.size = match v.parse() { 118 | Ok(v) => v, 119 | Err(_) => return Err(format!("could not parse size as integer: {}", v)), 120 | }, 121 | _ => return Err(format!("invalid header option: {}", v)) 122 | } 123 | } 124 | 125 | Ok(()) 126 | } 127 | } 128 | 129 | impl Default for TitleOptions { 130 | fn default() -> Self { 131 | Self { 132 | size: 30, 133 | label: None, 134 | color: None, 135 | font: None, 136 | } 137 | } 138 | } 139 | #[derive(Clone, Debug, Eq, PartialEq)] 140 | pub struct HeaderOptions { 141 | pub size: u8, 142 | pub font: String, 143 | pub border: u8, 144 | pub cell_border: u8, 145 | pub cell_spacing: u8, 146 | pub cell_padding: u8, 147 | 148 | pub background_color: Option, 149 | pub label: Option, 150 | pub color: Option, 151 | pub border_color: Option, 152 | } 153 | 154 | 155 | impl HeaderOptions { 156 | pub fn from_hashmap(m: &HashMap) -> Result { 157 | let mut opts = Self::default(); 158 | opts.merge_hashmap(m)?; 159 | Ok(opts) 160 | } 161 | 162 | pub fn merge_hashmap(&mut self, m: &HashMap) -> Result<(), String> { 163 | for (k, v) in m { 164 | match k.as_str() { 165 | OPT_SIZE => self.size = match v.parse() { 166 | Ok(v) => v, 167 | Err(_) => return Err(format!("could not parse size as integer: {}", v)), 168 | }, 169 | OPT_LABEL => self.label = Some(v.clone()), 170 | OPT_COLOR => self.color = Some(v.clone()), 171 | OPT_BACKGROUND_COLOR => self.background_color = Some(v.clone()), 172 | OPT_FONT => self.font = v.clone(), 173 | OPT_BORDER_COLOR => self.border_color = Some(v.clone()), 174 | OPT_BORDER => self.border = match v.parse() { 175 | Ok(v) => v, 176 | Err(_) => return Err(format!("could not parse border as integer: {}", v)), 177 | }, 178 | _ => return Err(format!("invalid header option: {}", v)) 179 | } 180 | } 181 | 182 | Ok(()) 183 | } 184 | } 185 | 186 | impl Default for HeaderOptions { 187 | fn default() -> Self { 188 | Self { 189 | size: 16, 190 | font: "Helvetica".to_owned(), 191 | border: 0, 192 | cell_border: 1, 193 | cell_padding: 4, 194 | cell_spacing: 0, 195 | background_color: None, 196 | label: None, 197 | color: None, 198 | border_color: None, 199 | } 200 | } 201 | } 202 | 203 | #[derive(Clone, Debug, Eq, PartialEq)] 204 | pub struct EntityOptions { 205 | pub border: u8, 206 | pub cell_border: u8, 207 | pub cell_spacing: u8, 208 | pub cell_padding: u8, 209 | pub font: String, 210 | 211 | pub background_color: Option, 212 | pub label: Option, 213 | pub color: Option, 214 | pub size: Option, 215 | pub border_color: Option, 216 | } 217 | 218 | impl EntityOptions { 219 | pub fn from_hashmap(m: &HashMap) -> Result { 220 | let mut opts = Self::default(); 221 | opts.merge_hashmap(m)?; 222 | Ok(opts) 223 | } 224 | 225 | pub fn merge_hashmap(&mut self, m: &HashMap) -> Result<(), String> { 226 | for (k, v) in m { 227 | match k.as_str() { 228 | OPT_BACKGROUND_COLOR => self.background_color = Some(v.clone()), 229 | OPT_LABEL => self.label = Some(v.clone()), 230 | OPT_COLOR => self.color = Some(v.clone()), 231 | OPT_SIZE => self.size = Some(match v.parse() { 232 | Ok(v) => v, 233 | Err(_) => return Err(format!("could not parse size as integer: {}", v)), 234 | }), 235 | OPT_FONT => self.font = v.clone(), 236 | OPT_BORDER_COLOR => self.border_color = Some(v.clone()), 237 | OPT_BORDER => self.border = match v.parse() { 238 | Ok(v) => v, 239 | Err(_) => return Err(format!("could not parse border as integer: {}", v)), 240 | }, 241 | _ => return Err(format!("invalid entity option: {}", v)) 242 | } 243 | } 244 | 245 | Ok(()) 246 | } 247 | } 248 | 249 | impl Default for EntityOptions { 250 | fn default() -> Self { 251 | Self { 252 | border: 0, 253 | cell_border: 1, 254 | cell_spacing: 0, 255 | cell_padding: 4, 256 | font: "Helvetica".to_owned(), 257 | background_color: None, 258 | label: None, 259 | color: None, 260 | size: None, 261 | border_color: None, 262 | } 263 | } 264 | } 265 | 266 | #[derive(Clone, Debug, Eq, PartialEq)] 267 | pub struct AttributeOptions { 268 | pub text_alignment: String, 269 | pub label: Option, 270 | pub color: Option, 271 | pub background_color: Option, 272 | pub font: Option, 273 | pub border: Option, 274 | pub border_color: Option, 275 | } 276 | 277 | impl AttributeOptions { 278 | pub fn from_hashmap(m: &HashMap) -> Result { 279 | let mut opts = Self::default(); 280 | opts.merge_hashmap(m)?; 281 | Ok(opts) 282 | } 283 | 284 | pub fn merge_hashmap(&mut self, m: &HashMap) -> Result<(), String> { 285 | for (k, v) in m { 286 | match k.as_str() { 287 | OPT_LABEL => self.label = Some(v.clone()), 288 | OPT_COLOR => self.color = Some(v.clone()), 289 | OPT_BACKGROUND_COLOR => self.background_color = Some(v.clone()), 290 | OPT_FONT => self.font = Some(v.clone()), 291 | OPT_BORDER_COLOR => self.border_color = Some(v.clone()), 292 | OPT_BORDER => self.border = Some(match v.parse() { 293 | Ok(v) => v, 294 | Err(_) => return Err(format!("could not parse border as integer: {}", v)), 295 | }), 296 | _ => return Err(format!("invalid attribute option: {}", v)) 297 | } 298 | } 299 | 300 | Ok(()) 301 | } 302 | } 303 | 304 | impl Default for AttributeOptions { 305 | fn default() -> Self { 306 | Self { 307 | text_alignment: "LEFT".to_owned(), 308 | label: None, 309 | color: None, 310 | background_color: None, 311 | font: None, 312 | border: None, 313 | border_color: None, 314 | } 315 | } 316 | } 317 | 318 | #[derive(Clone, Default, Debug, Eq, PartialEq)] 319 | pub struct RelationshipOptions { 320 | label: Option, 321 | color: Option, 322 | size: Option, 323 | font: Option, 324 | } 325 | 326 | impl RelationshipOptions { 327 | pub fn from_hashmap(m: &HashMap) -> Result { 328 | let mut opts = Self::default(); 329 | opts.merge_hashmap(m)?; 330 | Ok(opts) 331 | } 332 | 333 | pub fn merge_hashmap(&mut self, m: &HashMap) -> Result<(), String> { 334 | for (k, v) in m { 335 | match k.as_str() { 336 | OPT_LABEL => self.label = Some(v.clone()), 337 | OPT_COLOR => self.color = Some(v.clone()), 338 | OPT_SIZE => self.size = Some(match v.parse() { 339 | Ok(v) => v, 340 | Err(_) => return Err(format!("could not parse size as integer: {}", v)), 341 | }), 342 | OPT_FONT => self.font = Some(v.clone()), 343 | _ => return Err(format!("invalid relationship option: {}", v)) 344 | } 345 | } 346 | 347 | Ok(()) 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::{self, Read}}; 2 | mod ast; 3 | mod parser; 4 | mod render; 5 | 6 | fn main() { 7 | let args: Vec = std::env::args().collect(); 8 | let prog = args[0].clone(); 9 | 10 | let mut opts = getopts::Options::new(); 11 | opts.optopt("i", "input", "When set, input will be read from the given file, otherwise input will be read from stdin.", "FILE"); 12 | opts.optopt("o", "output", "When set, output will be written to the given file, otherwise output will be written to stdout.", "FILE"); 13 | opts.optflag("h", "help", "Print this help menu."); 14 | 15 | let matches = match opts.parse(&args[1..]) { 16 | Ok(m) => m, 17 | Err(_) => print_usage_fatal(&prog, opts), 18 | }; 19 | 20 | if matches.opt_present("h") { 21 | print_usage(&prog, opts); 22 | return; 23 | } 24 | 25 | let input_file = matches.opt_str("i"); 26 | let output_file = matches.opt_str("o"); 27 | 28 | // Ensure that no positional arguments are set. 29 | if !matches.free.is_empty() { 30 | print_usage_fatal(&prog, opts); 31 | } 32 | 33 | let input = match input_file { 34 | Some(s) => { 35 | std::fs::read_to_string(s).unwrap() 36 | }, 37 | None => { 38 | let mut buf = String::new(); 39 | io::stdin().read_to_string(&mut buf).unwrap(); 40 | buf 41 | } 42 | }; 43 | 44 | let erd = match parser::parse_erd(&input) { 45 | Ok(erd) => erd, 46 | Err(err) => { 47 | eprintln!("Failed to parse ERD file: {}", err); 48 | std::process::exit(1); 49 | } 50 | }; 51 | 52 | let output: Box = match output_file { 53 | Some(ref path) => { 54 | let f = match File::create(path) { 55 | Ok(f) => f, 56 | Err(err) => { 57 | eprintln!("Failed to open file '{}' for writing: {}", path, err); 58 | std::process::exit(1); 59 | } 60 | }; 61 | Box::new(f) 62 | }, 63 | None => Box::new(io::stdout()), 64 | }; 65 | 66 | let mut renderer = render::Renderer::new(output); 67 | if let Err(err) = renderer.render_erd(&erd) { 68 | eprintln!("Failed to render: {}", err); 69 | std::process::exit(1); 70 | } 71 | } 72 | 73 | fn print_usage(prog: &str, opts: getopts::Options) { 74 | let brief = format!("Usage: {} [options]", prog); 75 | print!("{}", opts.usage(&brief)); 76 | } 77 | 78 | fn print_usage_fatal(prog: &str, opts: getopts::Options) -> ! { 79 | let brief = format!("Usage: {} [options]", prog); 80 | eprint!("{}", opts.usage(&brief)); 81 | std::process::exit(1); 82 | } -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use crate::ast::{self, EntityOptions, GlobalOption, GlobalOptionType, HeaderOptions}; 2 | use std::collections::HashMap; 3 | use nom::{IResult, branch::alt, InputTakeAtPosition, AsChar, 4 | error::{ParseError, ErrorKind}, 5 | bytes::{ 6 | complete::{is_not, tag, take_while, take_while1}, 7 | }, 8 | character::{ 9 | complete::{ 10 | alphanumeric1, 11 | char, 12 | line_ending, 13 | one_of, 14 | space0, 15 | not_line_ending, 16 | multispace0, 17 | multispace1, 18 | }, 19 | is_alphanumeric 20 | }, combinator::{ 21 | value, 22 | map, 23 | opt, 24 | all_consuming, 25 | eof, 26 | }, multi::{ 27 | many0, 28 | separated_list0, 29 | }, 30 | sequence::{ 31 | delimited, 32 | separated_pair, 33 | terminated, 34 | preceded, 35 | }}; 36 | 37 | pub fn parse_erd<'a>(i: &'a str) -> Result { 38 | let a = match parse::<'a, ErdParseError<&str>>(i) { 39 | Ok((_m, a)) => a, 40 | Err(err) => return Err(err.to_string()), 41 | }; 42 | 43 | let mut entities = Vec::new(); 44 | let mut relationships = Vec::new(); 45 | let mut title_directive = HashMap::new(); 46 | let mut header_directive = HashMap::new(); 47 | let mut entity_directive = HashMap::new(); 48 | let mut relationship_directive = HashMap::new(); 49 | 50 | for o in a.into_iter() { 51 | match o { 52 | ast::Ast::Entity(mut e) => { 53 | e.options.merge_hashmap(&entity_directive)?; 54 | e.header_options.merge_hashmap(&header_directive)?; 55 | entities.push(e); 56 | }, 57 | ast::Ast::Relation(mut r) => { 58 | r.options.merge_hashmap(&relationship_directive)?; 59 | relationships.push(r); 60 | }, 61 | ast::Ast::Attribute(a) => { 62 | match entities.last_mut() { 63 | Some(e) => e.add_attribute(a), 64 | None => return Err(String::from("found attribute without a preceding entity to attach it to")), 65 | } 66 | }, 67 | ast::Ast::GlobalOption(ast::GlobalOption { option_type, options }) => { 68 | use ast::GlobalOptionType::*; 69 | match option_type { 70 | Title => title_directive.extend(options), 71 | Header => header_directive.extend(options), 72 | Entity => entity_directive.extend(options), 73 | Relationship => relationship_directive.extend(options), 74 | } 75 | } 76 | } 77 | } 78 | 79 | let mut title_options = ast::TitleOptions::default(); 80 | title_options.merge_hashmap(&title_directive)?; 81 | Ok(ast::Erd { entities, relationships, title_options }) 82 | } 83 | 84 | fn parse<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&str, Vec, ErdParseError<&str>> { 85 | let (i, mut global_opts) = many0( 86 | delimited( 87 | blank_or_comment, 88 | map(global_option, |g| ast::Ast::GlobalOption(g)), 89 | blank_or_comment, 90 | ) 91 | )(i)?; 92 | 93 | let (_, mut era) = all_consuming( 94 | delimited( 95 | blank_or_comment, 96 | 97 | many0( 98 | delimited( 99 | blank_or_comment, 100 | alt(( 101 | map(entity, |e| ast::Ast::Entity(e)), 102 | map(relation, |r| ast::Ast::Relation(r)), 103 | map(attribute, |a| ast::Ast::Attribute(a)), 104 | )), 105 | blank_or_comment, 106 | ) 107 | ), 108 | 109 | blank_or_comment, 110 | ) 111 | )(i)?; 112 | 113 | global_opts.append(&mut era); 114 | Ok((i, global_opts)) 115 | } 116 | 117 | fn comment(i: &str) -> IResult<&str, &str, ErdParseError<&str>> { 118 | delimited(char('#'), not_line_ending, alt((line_ending, eof)))(i) 119 | } 120 | 121 | fn blank_or_comment(i: &str) -> IResult<&str, Vec<&str>, ErdParseError<&str>> { 122 | many0(alt((multispace1, comment)))(i) 123 | } 124 | 125 | fn multispace_comma0>(input: T) -> IResult 126 | where 127 | T: InputTakeAtPosition, 128 | ::Item: AsChar + Clone, 129 | { 130 | input.split_at_position_complete(|item| { 131 | let c = item.as_char(); 132 | !(c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ',') 133 | }) 134 | } 135 | 136 | fn multispace0_comment(i: &str) -> IResult<&str, (), ErdParseError<&str>> { 137 | value( 138 | (), 139 | delimited( 140 | multispace0, 141 | opt(comment), 142 | multispace0, 143 | ) 144 | )(i) 145 | } 146 | 147 | fn entity(i: &str) -> IResult<&str, ast::Entity, ErdParseError<&str>> { 148 | let (i, name) = delimited(char('['), ident, char(']'))(i)?; 149 | let (i, opts) = trailing_options(i)?; 150 | 151 | let entity_options = match EntityOptions::from_hashmap(&opts) { 152 | Ok(o) => o, 153 | Err(e) => return Err(nom::Err::Error(ErdParseError::InvalidOption(e))), 154 | }; 155 | 156 | let header_options = match HeaderOptions::from_hashmap(&opts) { 157 | Ok(o) => o, 158 | Err(e) => return Err(nom::Err::Error(ErdParseError::InvalidOption(e))), 159 | }; 160 | 161 | Ok((i, ast::Entity { 162 | name: name.to_owned(), 163 | attribs: Vec::new(), 164 | options: entity_options, 165 | header_options: header_options, 166 | })) 167 | } 168 | 169 | fn attribute(i: &str) -> IResult<&str, ast::Attribute, ErdParseError<&str>> { 170 | let (i, key_types) = many0(one_of("*+ \t"))(i)?; 171 | 172 | let (i, field) = ident(i)?; 173 | let mut attr = ast::Attribute::with_field(field); 174 | for key_type in key_types { 175 | match key_type { 176 | '*' => attr.pk = true, 177 | '+' => attr.fk = true, 178 | ' ' | '\t' => {}, 179 | _ => panic!("unhandled key type: {:?}", key_type) 180 | } 181 | } 182 | 183 | let (i, opts) = trailing_options(i)?; 184 | 185 | let options = match ast::AttributeOptions::from_hashmap(&opts) { 186 | Ok(o) => o, 187 | Err(e) => return Err(nom::Err::Error(ErdParseError::InvalidOption(e))), 188 | }; 189 | 190 | attr.options = options; 191 | Ok((i, attr)) 192 | } 193 | 194 | fn relation(i: &str) -> IResult<&str, ast::Relation, ErdParseError<&str>> { 195 | let (i, entity1) = ident(i)?; 196 | let (i, (card1, card2)) = separated_pair( 197 | cardinality, 198 | tag("--"), 199 | cardinality, 200 | )(i)?; 201 | let (i, entity2) = ident(i)?; 202 | let (i, opts) = trailing_options(i)?; 203 | 204 | let options = match ast::RelationshipOptions::from_hashmap(&opts) { 205 | Ok(o) => o, 206 | Err(e) => return Err(nom::Err::Error(ErdParseError::InvalidOption(e))), 207 | }; 208 | 209 | let rel = ast::Relation { 210 | entity1: entity1.to_owned(), 211 | entity2: entity2.to_owned(), 212 | card1: card1.to_owned(), 213 | card2: card2.to_owned(), 214 | options, 215 | }; 216 | Ok((i, rel)) 217 | } 218 | 219 | fn cardinality(i: &str) -> IResult<&str, ast::Cardinality, ErdParseError<&str>> { 220 | let (i, op) = one_of("?1*+")(i)?; 221 | let c = match op { 222 | '?' => ast::Cardinality::ZeroOne, 223 | '1' => ast::Cardinality::One, 224 | '*' => ast::Cardinality::ZeroPlus, 225 | '+' => ast::Cardinality::OnePlus, 226 | _ => panic!("unhandled cardinality operand"), 227 | }; 228 | Ok((i, c)) 229 | } 230 | 231 | fn global_option(i: &str) -> IResult<&str, GlobalOption, ErdParseError<&str>> { 232 | let (i, name) = alt(( 233 | tag("title"), 234 | tag("header"), 235 | tag("entity"), 236 | tag("relationship"), 237 | ))(i)?; 238 | 239 | let option_type = match name { 240 | "title" => GlobalOptionType::Title, 241 | "header" => GlobalOptionType::Header, 242 | "entity" => GlobalOptionType::Entity, 243 | "relationship" => GlobalOptionType::Relationship, 244 | _ => panic!("unhandled global optional type"), 245 | }; 246 | 247 | let (i, options) = trailing_options(i)?; 248 | Ok((i, GlobalOption { option_type, options })) 249 | } 250 | 251 | fn option(i: &str) -> IResult<&str, (&str, &str), ErdParseError<&str>> { 252 | separated_pair( 253 | alphanumeric1, 254 | delimited(space0, char(':'), space0), 255 | quoted 256 | )(i) 257 | } 258 | 259 | fn trailing_options(i: &str) ->IResult<&str, HashMap, ErdParseError<&str>> { 260 | let (i, opts) = delimited(multispace0, opt(options), space0)(i)?; 261 | let opts: HashMap = if let Some(o) = opts { 262 | o.into_iter().map(|(k, v)| (k.to_owned(), v.to_owned())).collect() 263 | } else { 264 | HashMap::new() 265 | }; 266 | Ok((i, opts)) 267 | } 268 | 269 | fn options(i: &str) -> IResult<&str, Vec<(&str, &str)>, ErdParseError<&str>> { 270 | delimited( 271 | preceded(char('{'), multispace0), 272 | 273 | separated_list0( 274 | delimited( 275 | multispace0_comment, 276 | char(','), 277 | multispace0_comment, 278 | ), 279 | option 280 | ), 281 | 282 | terminated( 283 | delimited( 284 | multispace_comma0, 285 | multispace0_comment, 286 | multispace_comma0, 287 | ), 288 | char('}'), 289 | ), 290 | )(i) 291 | } 292 | 293 | fn quoted(i: &str) -> IResult<&str, &str, ErdParseError<&str>> { 294 | delimited(char('"'), is_not("\""), char('"'))(i) 295 | } 296 | 297 | fn ident(i: &str) -> IResult<&str, &str, ErdParseError<&str>> { 298 | let (i, id) = delimited(space0, alt(( 299 | ident_quoted, 300 | ident_no_space, 301 | )), space0)(i)?; 302 | Ok((i, id)) 303 | } 304 | 305 | fn ident_quoted(i: &str) -> IResult<&str, &str, ErdParseError<&str>> { 306 | let (i, id) = alt(( 307 | delimited(char('"'), take_while(|c: char| !c.is_control() && c != '"'), char('"')), 308 | delimited(char('\''), take_while(|c: char| !c.is_control() && c != '\''), char('\'')), 309 | delimited(char('`'), take_while(|c: char| !c.is_control() && c != '`'), char('`')), 310 | ))(i)?; 311 | Ok((i, id)) 312 | } 313 | 314 | fn ident_no_space(i: &str) -> IResult<&str, &str, ErdParseError<&str>> { 315 | take_while1(|c| is_alphanumeric(c as u8) || c == '_')(i) 316 | } 317 | 318 | #[derive(Debug, PartialEq)] 319 | pub enum ErdParseError { 320 | InvalidOption(String), 321 | Nom(I, ErrorKind), 322 | } 323 | 324 | impl nom::error::ParseError for ErdParseError { 325 | fn from_error_kind(input: I, kind: ErrorKind) -> Self { 326 | ErdParseError::Nom(input, kind) 327 | } 328 | 329 | fn append(_: I, _: ErrorKind, other: Self) -> Self { 330 | other 331 | } 332 | } 333 | 334 | #[cfg(test)] 335 | mod tests { 336 | use std::include_str; 337 | 338 | use maplit::hashmap; 339 | 340 | use super::*; 341 | 342 | #[test] 343 | fn test_parse_empty() { 344 | let s = ""; 345 | let e = parse_erd(s).unwrap(); 346 | assert_eq!(e.entities.len(), 0); 347 | assert_eq!(e.relationships.len(), 0); 348 | } 349 | 350 | #[test] 351 | fn test_parse_single_comment() { 352 | let s = "# Comment."; 353 | let e = parse_erd(s).unwrap(); 354 | assert_eq!(e.entities.len(), 0); 355 | assert_eq!(e.relationships.len(), 0); 356 | } 357 | 358 | #[test] 359 | fn test_parse_simple() { 360 | let s = include_str!("../examples/simple.er"); 361 | let e = parse_erd(s).unwrap(); 362 | assert_eq!(e.entities.len(), 2); 363 | assert_eq!(e.relationships.len(), 1); 364 | } 365 | 366 | #[test] 367 | fn test_parse_nfldb() { 368 | let s = include_str!("../examples/nfldb.er"); 369 | let e = parse_erd(s).unwrap(); 370 | assert_eq!(e.entities.len(), 7); 371 | assert_eq!(e.relationships.len(), 13); 372 | } 373 | 374 | #[test] 375 | fn test_blank_or_comment_empty() { 376 | blank_or_comment("").unwrap(); 377 | } 378 | 379 | #[test] 380 | fn test_blank_or_comment_no_eol() { 381 | blank_or_comment("# comment").unwrap(); 382 | } 383 | 384 | #[test] 385 | fn test_blank_or_comment_eol() { 386 | blank_or_comment("# comment\n").unwrap(); 387 | } 388 | 389 | #[test] 390 | fn test_blank_or_comment_whitespace() { 391 | blank_or_comment(" # comment \n ").unwrap(); 392 | } 393 | 394 | #[test] 395 | fn test_comments() { 396 | assert_eq!(comment("# foo\r\n"), Ok(("", " foo"))); 397 | } 398 | 399 | #[test] 400 | fn test_entity_simple() { 401 | let (i, e) = entity("[foo]").unwrap(); 402 | assert!(i.is_empty()); 403 | assert_eq!(e, new_entity("foo")); 404 | } 405 | 406 | #[test] 407 | fn test_entity_quoted() { 408 | let (i, e) = entity("[\"foo bar\"]").unwrap(); 409 | assert!(i.is_empty()); 410 | assert_eq!(e, new_entity("foo bar")); 411 | } 412 | 413 | #[test] 414 | fn test_entity_with_option() { 415 | let (i, e) = entity("[foo] {color: \"#1234AA\"}").unwrap(); 416 | assert!(i.is_empty()); 417 | let mut expected = new_entity("foo"); 418 | let o = &hashmap!{"color".to_owned() => "#1234AA".to_owned()}; 419 | expected.options = EntityOptions::from_hashmap(o).unwrap(); 420 | expected.header_options = HeaderOptions::from_hashmap(o).unwrap(); 421 | assert_eq!(e, expected); 422 | } 423 | 424 | #[test] 425 | fn test_entity_quoted_with_multiple_options() { 426 | let (i, e) = entity("[`foo - bar`] {size: \"10\", font: \"Equity\"}").unwrap(); 427 | assert!(i.is_empty()); 428 | let mut expected = new_entity("foo - bar"); 429 | let o = &hashmap!{ 430 | "size".to_owned() => "10".to_owned(), 431 | "font".to_owned() => "Equity".to_owned(), 432 | }; 433 | expected.options = EntityOptions::from_hashmap(o).unwrap(); 434 | expected.header_options = HeaderOptions::from_hashmap(o).unwrap(); 435 | assert_eq!(e, expected); 436 | } 437 | 438 | #[test] 439 | fn test_attribute_simple() { 440 | let (i, attr) = attribute("foo").unwrap(); 441 | assert_eq!(attr, ast::Attribute::with_field("foo")); 442 | assert!(i.is_empty()); 443 | } 444 | 445 | #[test] 446 | fn test_attribute_pk() { 447 | let (i, attr) = attribute("*foo").unwrap(); 448 | assert_eq!(attr, ast::Attribute { 449 | field: "foo".to_owned(), 450 | pk: true, 451 | ..Default::default() 452 | }); 453 | assert!(i.is_empty()); 454 | } 455 | 456 | #[test] 457 | fn test_attribute_multiple_pk() { 458 | let (i, attr) = attribute("***foo").unwrap(); 459 | assert_eq!(attr, ast::Attribute { 460 | field: "foo".to_owned(), 461 | pk: true, 462 | ..Default::default() 463 | }); 464 | assert!(i.is_empty()); 465 | } 466 | 467 | #[test] 468 | fn test_attribute_fk() { 469 | let (i, attr) = attribute("+foo").unwrap(); 470 | assert_eq!(attr, ast::Attribute { 471 | field: "foo".to_owned(), 472 | fk: true, 473 | ..Default::default() 474 | }); 475 | assert!(i.is_empty()); 476 | } 477 | 478 | #[test] 479 | fn test_attribute_pk_fk() { 480 | let (i, attr) = attribute("+*foo").unwrap(); 481 | assert_eq!(attr, ast::Attribute { 482 | field: "foo".to_owned(), 483 | pk: true, 484 | fk: true, 485 | ..Default::default() 486 | }); 487 | assert!(i.is_empty()); 488 | } 489 | 490 | #[test] 491 | fn test_attribute_multiple_pk_fk() { 492 | let (i, attr) = attribute("***++*foo").unwrap(); 493 | assert_eq!(attr, ast::Attribute { 494 | field: "foo".to_owned(), 495 | pk: true, 496 | fk: true, 497 | ..Default::default() 498 | }); 499 | assert!(i.is_empty()); 500 | } 501 | 502 | #[test] 503 | fn test_attribute_whitespace() { 504 | let (i, attr) = attribute(" \t foo").unwrap(); 505 | assert_eq!(attr, ast::Attribute { 506 | field: "foo".to_owned(), 507 | ..Default::default() 508 | }); 509 | assert!(i.is_empty()); 510 | } 511 | 512 | #[test] 513 | fn test_attribute_with_options() { 514 | let (i, attr) = attribute("*foo {label:\"b\", border : \"3\"}").unwrap(); 515 | assert_eq!(attr, ast::Attribute { 516 | field: "foo".to_owned(), 517 | pk: true, 518 | fk: false, 519 | options: ast::AttributeOptions::from_hashmap(&hashmap!{ 520 | "label".to_owned() => "b".to_owned(), 521 | "border".to_owned() => "3".to_owned(), 522 | }).unwrap(), 523 | }); 524 | assert!(i.is_empty()); 525 | } 526 | 527 | #[test] 528 | fn test_attribute_with_multiline_options() { 529 | let (i, attr) = attribute(r#"*foo { 530 | label:"b", 531 | border : "3" 532 | }"#).unwrap(); 533 | assert_eq!(attr, ast::Attribute { 534 | field: "foo".to_owned(), 535 | pk: true, 536 | fk: false, 537 | options: ast::AttributeOptions::from_hashmap(&hashmap!{ 538 | "label".to_owned() => "b".to_owned(), 539 | "border".to_owned() => "3".to_owned(), 540 | }).unwrap(), 541 | }); 542 | assert!(i.is_empty()); 543 | } 544 | 545 | #[test] 546 | fn test_attribute_with_multiline_options_trailing_comments() { 547 | let (i, attr) = attribute(r#"*foo { 548 | label:"b", 549 | border : "3", # comment 550 | }"#).unwrap(); 551 | assert_eq!(attr, ast::Attribute { 552 | field: "foo".to_owned(), 553 | pk: true, 554 | fk: false, 555 | options: ast::AttributeOptions::from_hashmap(&hashmap!{ 556 | "label".to_owned() => "b".to_owned(), 557 | "border".to_owned() => "3".to_owned(), 558 | }).unwrap(), 559 | }); 560 | assert!(i.is_empty()); 561 | } 562 | 563 | #[test] 564 | fn test_relation_one_oneplus() { 565 | let (i, rel) = relation("E1 1--+ E2").unwrap(); 566 | assert!(i.is_empty()); 567 | assert_eq!(rel, ast::Relation { 568 | entity1: "E1".to_owned(), 569 | entity2: "E2".to_owned(), 570 | card1: ast::Cardinality::One, 571 | card2: ast::Cardinality::OnePlus, 572 | options: ast::RelationshipOptions::default(), 573 | }); 574 | } 575 | 576 | #[test] 577 | fn test_relation_zeroplus_zeroone() { 578 | let (i, rel) = relation("`Entity 1` *--? 'Entity 2'").unwrap(); 579 | assert!(i.is_empty()); 580 | assert_eq!(rel, ast::Relation { 581 | entity1: "Entity 1".to_owned(), 582 | entity2: "Entity 2".to_owned(), 583 | card1: ast::Cardinality::ZeroPlus, 584 | card2: ast::Cardinality::ZeroOne, 585 | options: ast::RelationshipOptions::default(), 586 | }); 587 | } 588 | 589 | #[test] 590 | fn test_relation_with_options() { 591 | let (i, rel) = relation(r##"E1 1--1 E2 {color:"#000000", size: "1"}"##).unwrap(); 592 | assert!(i.is_empty()); 593 | assert_eq!(rel, ast::Relation { 594 | entity1: "E1".to_owned(), 595 | entity2: "E2".to_owned(), 596 | card1: ast::Cardinality::One, 597 | card2: ast::Cardinality::One, 598 | options: ast::RelationshipOptions::from_hashmap(&hashmap!{ 599 | "color".to_owned() => "#000000".to_owned(), 600 | "size".to_owned() => "1".to_owned(), 601 | }).unwrap(), 602 | }); 603 | } 604 | 605 | #[test] 606 | fn test_ident_no_space() { 607 | let (i, id) = ident_no_space("foo").unwrap(); 608 | assert!(i.is_empty()); 609 | assert_eq!(id, "foo"); 610 | 611 | let (i, id) = ident_no_space("foo_BAR").unwrap(); 612 | assert!(i.is_empty()); 613 | assert_eq!(id, "foo_BAR"); 614 | } 615 | 616 | #[test] 617 | fn test_ident_quoted() { 618 | let (i, id) = ident_quoted("\"foo\"").unwrap(); 619 | assert!(i.is_empty()); 620 | assert_eq!(id, "foo"); 621 | 622 | let (i, id) = ident_quoted("'foo'").unwrap(); 623 | assert!(i.is_empty()); 624 | assert_eq!(id, "foo"); 625 | 626 | let (i, id) = ident_quoted("`foo`").unwrap(); 627 | assert!(i.is_empty()); 628 | assert_eq!(id, "foo"); 629 | 630 | let (i, id) = ident_quoted("\"foo_BAR\"").unwrap(); 631 | assert!(i.is_empty()); 632 | assert_eq!(id, "foo_BAR"); 633 | 634 | let (i, id) = ident_quoted("\"foo - 'foo@bar' BAR\"").unwrap(); 635 | assert!(i.is_empty()); 636 | assert_eq!(id, "foo - 'foo@bar' BAR"); 637 | } 638 | 639 | #[test] 640 | fn test_ident() { 641 | let (i, id) = ident("\"foo\"").unwrap(); 642 | assert!(i.is_empty()); 643 | assert_eq!(id, "foo"); 644 | 645 | let (i, id) = ident("'foo'").unwrap(); 646 | assert!(i.is_empty()); 647 | assert_eq!(id, "foo"); 648 | 649 | let (i, id) = ident("`foo`").unwrap(); 650 | assert!(i.is_empty()); 651 | assert_eq!(id, "foo"); 652 | 653 | let (i, id) = ident("\"foo_BAR\"").unwrap(); 654 | assert!(i.is_empty()); 655 | assert_eq!(id, "foo_BAR"); 656 | 657 | let (i, id) = ident("\"foo - 'foo@bar' BAR\"").unwrap(); 658 | assert_eq!(i, ""); 659 | assert_eq!(id, "foo - 'foo@bar' BAR"); 660 | 661 | let (i, id) = ident(" foo ").unwrap(); 662 | assert!(i.is_empty()); 663 | assert_eq!(id, "foo"); 664 | 665 | let (i, id) = ident(" \t'foo'\t ").unwrap(); 666 | assert!(i.is_empty()); 667 | assert_eq!(id, "foo"); 668 | 669 | let (i, id) = ident(" \t `foo \"and\" bar` \t ").unwrap(); 670 | assert!(i.is_empty()); 671 | assert_eq!(id, "foo \"and\" bar"); 672 | } 673 | 674 | #[test] 675 | fn test_option() { 676 | let (i, (key, value)) = option(r#"foo: "bar""#).unwrap(); 677 | assert!(i.is_empty()); 678 | assert_eq!((key, value), ("foo", "bar")); 679 | 680 | let (i, (key, value)) = option(r#"foo:"A longer value?""#).unwrap(); 681 | assert!(i.is_empty()); 682 | assert_eq!((key, value), ("foo", "A longer value?")); 683 | } 684 | 685 | #[test] 686 | fn test_options() { 687 | let (i, opts) = options(r#"{k:"v"}"#).unwrap(); 688 | assert!(i.is_empty()); 689 | assert_eq!(opts, vec![("k", "v")]); 690 | 691 | let (i, opts) = options(r#"{ k:"v" }"#).unwrap(); 692 | assert!(i.is_empty()); 693 | assert_eq!(opts, vec![("k", "v")]); 694 | 695 | let (i, opts) = options(r#"{k1:"v1",k2:"v2"}"#).unwrap(); 696 | assert!(i.is_empty()); 697 | assert_eq!(opts, vec![("k1", "v1"), ("k2", "v2")]); 698 | 699 | let (i, opts) = options(r#"{k:"v1",k:"v2",k:"v3"}"#).unwrap(); 700 | assert!(i.is_empty()); 701 | assert_eq!(opts, vec![("k", "v1"), ("k", "v2"), ("k", "v3")]); 702 | 703 | let (i, opts) = options(r#"{ k1:"v1", k2:"v2" , k3:"v3"}"#).unwrap(); 704 | assert!(i.is_empty()); 705 | assert_eq!(opts, vec![("k1", "v1"), ("k2", "v2"), ("k3", "v3")]); 706 | } 707 | 708 | #[test] 709 | fn test_options_trailing_comma() { 710 | let (i, opts) = options(r#"{k1:"v1",}"#).unwrap(); 711 | assert!(i.is_empty()); 712 | assert_eq!(opts, vec![("k1", "v1")]); 713 | 714 | let (i, opts) = options(r#"{k1:"v1", }"#).unwrap(); 715 | assert!(i.is_empty()); 716 | assert_eq!(opts, vec![("k1", "v1")]); 717 | 718 | let (i, opts) = options(r#"{k1:"v1" , }"#).unwrap(); 719 | assert!(i.is_empty()); 720 | assert_eq!(opts, vec![("k1", "v1")]); 721 | } 722 | 723 | #[test] 724 | fn test_options_multiline() { 725 | let i = r##"{ 726 | label: "string", 727 | color: "#3366ff", # i like bright blue 728 | }"##; 729 | 730 | let (i, opts) = options(i).unwrap(); 731 | assert!(i.is_empty()); 732 | assert_eq!(opts, vec![("label", "string"), ("color", "#3366ff")]); 733 | } 734 | 735 | #[test] 736 | fn test_global_options() { 737 | let (i, go) = global_option("title {}").unwrap(); 738 | assert!(i.is_empty()); 739 | assert_eq!(go.option_type, GlobalOptionType::Title); 740 | assert!(go.options.is_empty()); 741 | 742 | let (i, go) = global_option(r#"header {k: "v"}"#).unwrap(); 743 | assert!(i.is_empty()); 744 | assert_eq!(go.option_type, GlobalOptionType::Header); 745 | assert_eq!(go.options.len(), 1); 746 | assert_eq!(go.options["k"], "v"); 747 | 748 | let (i, go) = global_option(r#"entity {k1: "v1", k2: "v2"}"#).unwrap(); 749 | assert!(i.is_empty()); 750 | assert_eq!(go.option_type, GlobalOptionType::Entity); 751 | assert_eq!(go.options.len(), 2); 752 | assert_eq!(go.options["k1"], "v1"); 753 | assert_eq!(go.options["k2"], "v2"); 754 | 755 | let (i, go) = global_option(r#"relationship{ k1:"X" , k2 : "v2", k1:"v1" }"#).unwrap(); 756 | println!("{}", i); 757 | assert!(i.is_empty()); 758 | assert_eq!(go.option_type, GlobalOptionType::Relationship); 759 | assert_eq!(go.options.len(), 2); 760 | assert_eq!(go.options["k1"], "v1"); 761 | assert_eq!(go.options["k2"], "v2"); 762 | 763 | assert!(global_option(r#"something {}"#).is_err()); 764 | } 765 | 766 | fn new_entity>(name: S) -> ast::Entity { 767 | ast::Entity { 768 | name: name.into(), 769 | attribs: Vec::default(), 770 | options: ast::EntityOptions::default(), 771 | header_options: ast::HeaderOptions::default(), 772 | } 773 | } 774 | } 775 | -------------------------------------------------------------------------------- /src/render.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Write, Result}; 2 | use crate::ast; 3 | 4 | pub struct Renderer { 5 | w: W 6 | } 7 | 8 | impl Renderer { 9 | pub fn new(w: W) -> Self { 10 | Self { w } 11 | } 12 | 13 | pub fn render_erd(&mut self, erd: &ast::Erd) -> Result<()> { 14 | self.graph_header()?; 15 | 16 | let mut graph_attrs = Vec::new(); 17 | 18 | if let Some(label) = &erd.title_options.label { 19 | graph_attrs.push(( 20 | "label", 21 | format!("<{}>", erd.title_options.size, label), 22 | )); 23 | graph_attrs.push(("labeljust", "l".to_owned())); 24 | graph_attrs.push(("labelloc", "t".to_owned())); 25 | } 26 | 27 | graph_attrs.push(("rankdir", "LR".to_owned())); 28 | graph_attrs.push(("splines", "spline".to_owned())); 29 | 30 | self.graph_attributes(&graph_attrs)?; 31 | 32 | self.node_attributes(&vec![ 33 | ("label", r#""\N""#.to_owned()), 34 | ("shape", "plaintext".to_owned()), 35 | ])?; 36 | 37 | self.edge_attributes(&vec![ 38 | ("color", "gray50".to_owned()), 39 | ("minlen", "2".to_owned()), 40 | ("style", "dashed".to_owned()), 41 | ])?; 42 | 43 | for e in &erd.entities { 44 | self.entity(e)?; 45 | } 46 | 47 | for r in &erd.relationships { 48 | self.relationship(r)?; 49 | } 50 | 51 | self.graph_footer() 52 | } 53 | 54 | fn graph_header(&mut self) -> Result<()> { 55 | write!(self.w, "graph {{\n") 56 | } 57 | 58 | fn render_attribute(&mut self, a: &ast::Attribute) -> Result<()> { 59 | let field = match (a.pk, a.fk) { 60 | (true, true) => format!("{}", a.field), 61 | (true, false) => format!("{}", a.field), 62 | (false, true) => format!("{}", a.field), 63 | (false, false) => a.field.clone(), 64 | }; 65 | write!(self.w, " ")?; 66 | self.open_tag("TR")?; 67 | self.open_tag_attrs("TD", &[("ALIGN", "LEFT".to_owned())])?; 68 | match &a.options.label { 69 | Some(l) => write!(self.w, "{} [{}]", field, l)?, 70 | None => write!(self.w, "{}", a.field)?, 71 | } 72 | self.close_tag("TD")?; 73 | self.close_tag("TR")?; 74 | write!(self.w, "\n") 75 | } 76 | 77 | fn open_tag(&mut self, tag: &str) -> Result<()> { 78 | write!(self.w, "<{}>", tag) 79 | } 80 | 81 | fn open_tag_attrs(&mut self, tag: &str, attrs: &[(&str, String)]) -> Result<()> { 82 | write!(self.w, "<{}", tag)?; 83 | for (k, v) in attrs { 84 | write!(self.w, " {}=\"{}\"", k, v)?; 85 | } 86 | write!(self.w, ">") 87 | } 88 | 89 | fn close_tag(&mut self, tag: &str) -> Result<()> { 90 | write!(self.w, "", tag) 91 | } 92 | 93 | fn relationship(&mut self, r: &ast::Relation) -> Result<()> { 94 | let head_card = match r.card2 { 95 | ast::Cardinality::ZeroOne => "{0,1}", 96 | ast::Cardinality::One => "1", 97 | ast::Cardinality::ZeroPlus => "0..N", 98 | ast::Cardinality::OnePlus => "1..N", 99 | }; 100 | let tail_card = match r.card1 { 101 | ast::Cardinality::ZeroOne => "{0,1}", 102 | ast::Cardinality::One => "1", 103 | ast::Cardinality::ZeroPlus => "0..N", 104 | ast::Cardinality::OnePlus => "1..N", 105 | }; 106 | write!(self.w, r#" "{}" -- "{}" [ headlabel="{}", taillabel="{}" ]; 107 | "#, r.entity1, r.entity2, head_card, tail_card) 108 | } 109 | 110 | fn entity(&mut self, e: &ast::Entity) -> Result<()> { 111 | write!(self.w, r#" "{name}" [ 112 | label=< 113 | "#, name=e.name)?; 114 | 115 | self.open_tag_attrs("FONT", &[("FACE", e.header_options.font.clone())])?; 116 | write!(self.w, "\n ")?; 117 | 118 | let mut attrs = Vec::new(); 119 | attrs.push(("BORDER", e.header_options.border.to_string())); 120 | attrs.push(("CELLBORDER", e.header_options.cell_border.to_string())); 121 | attrs.push(("CELLPADDING", e.header_options.cell_padding.to_string())); 122 | attrs.push(("CELLSPACING", e.header_options.cell_spacing.to_string())); 123 | 124 | if let Some(c) = &e.options.background_color { 125 | attrs.push(("BGCOLOR", c.clone())) 126 | } 127 | self.open_tag_attrs("TABLE", &attrs)?; 128 | 129 | write!( 130 | self.w, 131 | "\n {name}\n", 132 | size=e.header_options.size, 133 | name=e.name, 134 | )?; 135 | 136 | for a in &e.attribs { 137 | self.render_attribute(a)?; 138 | } 139 | 140 | write!(self.w, r#" 141 | 142 | >]; 143 | "#)?; 144 | 145 | Ok(()) 146 | } 147 | 148 | fn graph_attributes(&mut self, opts: &Vec<(&str, String)>) -> Result<()> { 149 | self.attributes("graph", opts) 150 | } 151 | 152 | fn node_attributes(&mut self, opts: &Vec<(&str, String)>) -> Result<()> { 153 | self.attributes("node", opts) 154 | } 155 | 156 | fn edge_attributes(&mut self, opts: &Vec<(&str, String)>) -> Result<()> { 157 | self.attributes("edge", opts) 158 | } 159 | 160 | fn attributes(&mut self, name: &str, opts: &Vec<(&str, String)>) -> Result<()> { 161 | write!(self.w, " {} [\n", name)?; 162 | for (key, value) in opts { 163 | write!(self.w, " {}={},\n", key, value)?; 164 | } 165 | write!(self.w, " ];\n") 166 | } 167 | 168 | fn graph_footer(&mut self) -> Result<()> { 169 | write!(self.w, "}}\n") 170 | } 171 | 172 | 173 | } 174 | 175 | 176 | 177 | 178 | #[cfg(test)] 179 | mod tests { 180 | use super::*; 181 | use crate::parser::parse_erd; 182 | use std::str::from_utf8; 183 | use pretty_assertions::assert_eq; 184 | 185 | #[test] 186 | fn empty_graph() { 187 | let erd = ast::Erd::default(); 188 | let mut buf = Vec::new(); 189 | let mut renderer = Renderer::new(&mut buf); 190 | renderer.render_erd(&erd).unwrap(); 191 | assert_eq!(from_utf8(&buf).unwrap(), r#"graph { 192 | graph [ 193 | rankdir=LR, 194 | splines=spline, 195 | ]; 196 | node [ 197 | label="\N", 198 | shape=plaintext, 199 | ]; 200 | edge [ 201 | color=gray50, 202 | minlen=2, 203 | style=dashed, 204 | ]; 205 | } 206 | "#); 207 | } 208 | 209 | #[test] 210 | fn title_and_entity() { 211 | let s = r#" 212 | title {label: "Foo"} 213 | 214 | [thing] 215 | "#; 216 | let erd = parse_erd(s).unwrap(); 217 | let mut buf = Vec::new(); 218 | let mut renderer = Renderer::new(&mut buf); 219 | renderer.render_erd(&erd).unwrap(); 220 | assert_eq!(from_utf8(&buf).unwrap(), r##"graph { 221 | graph [ 222 | label=<Foo>, 223 | labeljust=l, 224 | labelloc=t, 225 | rankdir=LR, 226 | splines=spline, 227 | ]; 228 | node [ 229 | label="\N", 230 | shape=plaintext, 231 | ]; 232 | edge [ 233 | color=gray50, 234 | minlen=2, 235 | style=dashed, 236 | ]; 237 | "thing" [ 238 | label=< 239 | 240 | 241 | 242 |
thing
243 |
244 | >]; 245 | } 246 | "##); 247 | } 248 | 249 | #[test] 250 | fn simple() { 251 | let s = include_str!("../examples/simple.er"); 252 | let erd = parse_erd(s).unwrap(); 253 | let mut buf = Vec::new(); 254 | let mut renderer = Renderer::new(&mut buf); 255 | renderer.render_erd(&erd).unwrap(); 256 | assert_eq!(from_utf8(&buf).unwrap(), r##"graph { 257 | graph [ 258 | rankdir=LR, 259 | splines=spline, 260 | ]; 261 | node [ 262 | label="\N", 263 | shape=plaintext, 264 | ]; 265 | edge [ 266 | color=gray50, 267 | minlen=2, 268 | style=dashed, 269 | ]; 270 | "Person" [ 271 | label=< 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 |
Person
name
height
weight
birth date
birth_place_id
281 |
282 | >]; 283 | "Birth Place" [ 284 | label=< 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 |
Birth Place
id
birth city
birth state
birth country
293 |
294 | >]; 295 | "Person" -- "Birth Place" [ headlabel="1", taillabel="0..N" ]; 296 | } 297 | "##); 298 | 299 | } 300 | 301 | #[test] 302 | fn test_empty_graph_with_opts() { 303 | let mut buf = Vec::new(); 304 | let mut renderer = Renderer::new(&mut buf); 305 | renderer.graph_header().unwrap(); 306 | renderer.graph_attributes( 307 | &vec![ 308 | ("a", "b".to_owned()), 309 | ("c", "\"d\"".to_owned()) 310 | ], 311 | ).unwrap(); 312 | renderer.graph_footer().unwrap(); 313 | assert_eq!(from_utf8(&buf).unwrap(), 314 | r#"graph { 315 | graph [ 316 | a=b, 317 | c="d", 318 | ]; 319 | } 320 | "#); 321 | } 322 | 323 | #[test] 324 | fn render_file() { 325 | // let mut f = std::fs::File::create("/tmp/out.dot").unwrap(); 326 | // graph_header(&mut f).unwrap(); 327 | 328 | // graph_attributes(&mut f, &[ 329 | // ("label", "<T>"), 330 | // ("labeljust", "l"), 331 | // ("labelloc", "t"), 332 | // ("rankdir", "LR"), 333 | // ("splines", "spline"), 334 | // ]).unwrap(); 335 | 336 | // node_attributes(&mut f, &[ 337 | // ("label", r#""\N""#), 338 | // ("shape", "plaintext"), 339 | // ]).unwrap(); 340 | 341 | // edge_attributes(&mut f, &[ 342 | // ("color", "gray50"), 343 | // ("minlen", "2"), 344 | // ("style", "dashed"), 345 | // ]).unwrap(); 346 | 347 | // let s = std::include_str!("../examples/simple.er"); 348 | // let erd = crate::parser::parse_erd(s).unwrap(); 349 | // render(&mut f, &erd).unwrap(); 350 | } 351 | } --------------------------------------------------------------------------------