|
| 1 | +import * as monaco from "monaco-editor"; |
| 2 | + |
| 3 | +// --------------------------------------------------------------------------- |
| 4 | +// Types |
| 5 | +// --------------------------------------------------------------------------- |
| 6 | + |
| 7 | +interface DocLink { |
| 8 | + url: string; |
| 9 | + label: string; |
| 10 | +} |
| 11 | + |
| 12 | +interface LinkMatch { |
| 13 | + startCol: number; // 1-based column |
| 14 | + endCol: number; // 1-based column (exclusive) |
| 15 | + link: DocLink; |
| 16 | +} |
| 17 | + |
| 18 | +// --------------------------------------------------------------------------- |
| 19 | +// Keyword → doc URL mappings |
| 20 | +// --------------------------------------------------------------------------- |
| 21 | + |
| 22 | +const CLAUSE_LINKS: Record<string, DocLink> = { |
| 23 | + visualise: { url: "syntax/clause/visualise", label: "VISUALISE clause" }, |
| 24 | + visualize: { url: "syntax/clause/visualise", label: "VISUALISE clause" }, |
| 25 | + draw: { url: "syntax/clause/draw", label: "DRAW clause" }, |
| 26 | + place: { url: "syntax/clause/place", label: "PLACE clause" }, |
| 27 | + scale: { url: "syntax/clause/scale", label: "SCALE clause" }, |
| 28 | + facet: { url: "syntax/clause/facet", label: "FACET clause" }, |
| 29 | + project: { url: "syntax/clause/project", label: "PROJECT clause" }, |
| 30 | + label: { url: "syntax/clause/label", label: "LABEL clause" }, |
| 31 | +}; |
| 32 | + |
| 33 | +const GEOM_LINKS: Record<string, DocLink> = { |
| 34 | + point: { url: "syntax/layer/type/point", label: "point layer" }, |
| 35 | + line: { url: "syntax/layer/type/line", label: "line layer" }, |
| 36 | + path: { url: "syntax/layer/type/path", label: "path layer" }, |
| 37 | + bar: { url: "syntax/layer/type/bar", label: "bar layer" }, |
| 38 | + area: { url: "syntax/layer/type/area", label: "area layer" }, |
| 39 | + rect: { url: "syntax/layer/type/rect", label: "rect layer" }, |
| 40 | + polygon: { url: "syntax/layer/type/polygon", label: "polygon layer" }, |
| 41 | + ribbon: { url: "syntax/layer/type/ribbon", label: "ribbon layer" }, |
| 42 | + histogram: { url: "syntax/layer/type/histogram", label: "histogram layer" }, |
| 43 | + density: { url: "syntax/layer/type/density", label: "density layer" }, |
| 44 | + smooth: { url: "syntax/layer/type/smooth", label: "smooth layer" }, |
| 45 | + boxplot: { url: "syntax/layer/type/boxplot", label: "boxplot layer" }, |
| 46 | + violin: { url: "syntax/layer/type/violin", label: "violin layer" }, |
| 47 | + text: { url: "syntax/layer/type/text", label: "text layer" }, |
| 48 | + segment: { url: "syntax/layer/type/segment", label: "segment layer" }, |
| 49 | + rule: { url: "syntax/layer/type/rule", label: "rule layer" }, |
| 50 | + linear: { url: "syntax/layer/type/linear", label: "linear layer" }, |
| 51 | + errorbar: { url: "syntax/layer/type/errorbar", label: "errorbar layer" }, |
| 52 | +}; |
| 53 | + |
| 54 | +const COORD_LINKS: Record<string, DocLink> = { |
| 55 | + cartesian: { url: "syntax/coord/cartesian", label: "cartesian coordinates" }, |
| 56 | + polar: { url: "syntax/coord/polar", label: "polar coordinates" }, |
| 57 | +}; |
| 58 | + |
| 59 | +const SCALE_TYPE_LINKS: Record<string, DocLink> = { |
| 60 | + continuous: { |
| 61 | + url: "syntax/scale/type/continuous", |
| 62 | + label: "continuous scale", |
| 63 | + }, |
| 64 | + discrete: { url: "syntax/scale/type/discrete", label: "discrete scale" }, |
| 65 | + binned: { url: "syntax/scale/type/binned", label: "binned scale" }, |
| 66 | + ordinal: { url: "syntax/scale/type/ordinal", label: "ordinal scale" }, |
| 67 | + identity: { url: "syntax/scale/type/identity", label: "identity scale" }, |
| 68 | +}; |
| 69 | + |
| 70 | +const AESTHETIC_LINKS: Record<string, DocLink> = { |
| 71 | + x: { url: "syntax/scale/aesthetic/0_position", label: "position aesthetic" }, |
| 72 | + y: { url: "syntax/scale/aesthetic/0_position", label: "position aesthetic" }, |
| 73 | + xmin: { |
| 74 | + url: "syntax/scale/aesthetic/0_position", |
| 75 | + label: "position aesthetic", |
| 76 | + }, |
| 77 | + xmax: { |
| 78 | + url: "syntax/scale/aesthetic/0_position", |
| 79 | + label: "position aesthetic", |
| 80 | + }, |
| 81 | + ymin: { |
| 82 | + url: "syntax/scale/aesthetic/0_position", |
| 83 | + label: "position aesthetic", |
| 84 | + }, |
| 85 | + ymax: { |
| 86 | + url: "syntax/scale/aesthetic/0_position", |
| 87 | + label: "position aesthetic", |
| 88 | + }, |
| 89 | + xend: { |
| 90 | + url: "syntax/scale/aesthetic/0_position", |
| 91 | + label: "position aesthetic", |
| 92 | + }, |
| 93 | + yend: { |
| 94 | + url: "syntax/scale/aesthetic/0_position", |
| 95 | + label: "position aesthetic", |
| 96 | + }, |
| 97 | + color: { url: "syntax/scale/aesthetic/1_color", label: "color aesthetic" }, |
| 98 | + colour: { url: "syntax/scale/aesthetic/1_color", label: "color aesthetic" }, |
| 99 | + fill: { url: "syntax/scale/aesthetic/1_color", label: "color aesthetic" }, |
| 100 | + stroke: { url: "syntax/scale/aesthetic/1_color", label: "color aesthetic" }, |
| 101 | + opacity: { |
| 102 | + url: "syntax/scale/aesthetic/2_opacity", |
| 103 | + label: "opacity aesthetic", |
| 104 | + }, |
| 105 | + linetype: { |
| 106 | + url: "syntax/scale/aesthetic/linetype", |
| 107 | + label: "linetype aesthetic", |
| 108 | + }, |
| 109 | + linewidth: { |
| 110 | + url: "syntax/scale/aesthetic/linewidth", |
| 111 | + label: "linewidth aesthetic", |
| 112 | + }, |
| 113 | + shape: { url: "syntax/scale/aesthetic/shape", label: "shape aesthetic" }, |
| 114 | + size: { url: "syntax/scale/aesthetic/size", label: "size aesthetic" }, |
| 115 | + panel: { |
| 116 | + url: "syntax/scale/aesthetic/Z_faceting", |
| 117 | + label: "faceting aesthetic", |
| 118 | + }, |
| 119 | + row: { |
| 120 | + url: "syntax/scale/aesthetic/Z_faceting", |
| 121 | + label: "faceting aesthetic", |
| 122 | + }, |
| 123 | + column: { |
| 124 | + url: "syntax/scale/aesthetic/Z_faceting", |
| 125 | + label: "faceting aesthetic", |
| 126 | + }, |
| 127 | +}; |
| 128 | + |
| 129 | +const POSITION_LINKS: Record<string, DocLink> = { |
| 130 | + stack: { url: "syntax/layer/position/stack", label: "stack position" }, |
| 131 | + dodge: { url: "syntax/layer/position/dodge", label: "dodge position" }, |
| 132 | + jitter: { url: "syntax/layer/position/jitter", label: "jitter position" }, |
| 133 | + identity: { |
| 134 | + url: "syntax/layer/position/identity", |
| 135 | + label: "identity position", |
| 136 | + }, |
| 137 | +}; |
| 138 | + |
| 139 | +// --------------------------------------------------------------------------- |
| 140 | +// Regex patterns |
| 141 | +// --------------------------------------------------------------------------- |
| 142 | + |
| 143 | +const CLAUSE_RE = |
| 144 | + /\b(VISUALISE|VISUALIZE|DRAW|PLACE|SCALE|FACET|PROJECT|LABEL)\b/gi; |
| 145 | + |
| 146 | +const GEOM_RE = |
| 147 | + /\b(?:DRAW|PLACE)\s+(point|line|path|bar|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|segment|rule|linear|errorbar)\b/gi; |
| 148 | + |
| 149 | +const COORD_RE = /\bTO\s+(cartesian|polar)\b/gi; |
| 150 | + |
| 151 | +const SCALE_TYPE_RE = |
| 152 | + /\bSCALE\s+(CONTINUOUS|DISCRETE|BINNED|ORDINAL|IDENTITY)\b/gi; |
| 153 | + |
| 154 | +const AESTHETIC_AFTER_AS_RE = |
| 155 | + /\bAS\s+(x|y|xmin|xmax|ymin|ymax|xend|yend|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|panel|row|column)\b/gi; |
| 156 | + |
| 157 | +const AESTHETIC_AFTER_SCALE_RE = |
| 158 | + /\bSCALE\s+(?:(?:CONTINUOUS|DISCRETE|BINNED|ORDINAL|IDENTITY)\s+)?(x|y|xmin|xmax|ymin|ymax|xend|yend|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|panel|row|column)\b/gi; |
| 159 | + |
| 160 | +const POSITION_RE = |
| 161 | + /\bposition\s*=>\s*'(stack|dodge|jitter|identity)'/gi; |
| 162 | + |
| 163 | +// --------------------------------------------------------------------------- |
| 164 | +// Line scanning |
| 165 | +// --------------------------------------------------------------------------- |
| 166 | + |
| 167 | +/** |
| 168 | + * Find all linkable keyword matches in a single line of text. |
| 169 | + * Returns matches with 1-based column positions. |
| 170 | + */ |
| 171 | +function findLinksInLine(lineText: string): LinkMatch[] { |
| 172 | + const matches: LinkMatch[] = []; |
| 173 | + |
| 174 | + // Helper: run a regex, look up the capture group in a map, push match |
| 175 | + function scan( |
| 176 | + re: RegExp, |
| 177 | + map: Record<string, DocLink>, |
| 178 | + captureGroup: number = 0, |
| 179 | + ) { |
| 180 | + re.lastIndex = 0; |
| 181 | + let m: RegExpExecArray | null; |
| 182 | + while ((m = re.exec(lineText)) !== null) { |
| 183 | + const word = m[captureGroup]; |
| 184 | + const link = map[word.toLowerCase()]; |
| 185 | + if (!link) continue; |
| 186 | + |
| 187 | + // The capture group is always the last token in our patterns |
| 188 | + const startOffset = |
| 189 | + captureGroup === 0 |
| 190 | + ? m.index |
| 191 | + : m.index + m[0].length - word.length; |
| 192 | + |
| 193 | + matches.push({ |
| 194 | + startCol: startOffset + 1, // 1-based |
| 195 | + endCol: startOffset + word.length + 1, |
| 196 | + link, |
| 197 | + }); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + // 1. Clause keywords (full match) |
| 202 | + scan(CLAUSE_RE, CLAUSE_LINKS, 1); |
| 203 | + |
| 204 | + // 2. Geom types (capture group 1, after DRAW/PLACE) |
| 205 | + scan(GEOM_RE, GEOM_LINKS, 1); |
| 206 | + |
| 207 | + // 3. Coord types (capture group 1, after TO) |
| 208 | + scan(COORD_RE, COORD_LINKS, 1); |
| 209 | + |
| 210 | + // 4. Scale modifiers (capture group 1, after SCALE) |
| 211 | + scan(SCALE_TYPE_RE, SCALE_TYPE_LINKS, 1); |
| 212 | + |
| 213 | + // 5. Aesthetics after AS (capture group 1) |
| 214 | + scan(AESTHETIC_AFTER_AS_RE, AESTHETIC_LINKS, 1); |
| 215 | + |
| 216 | + // 6. Aesthetics after SCALE [modifier?] (capture group 1) |
| 217 | + scan(AESTHETIC_AFTER_SCALE_RE, AESTHETIC_LINKS, 1); |
| 218 | + |
| 219 | + // 7. Position adjustments (capture group 1, inside quotes) |
| 220 | + scan(POSITION_RE, POSITION_LINKS, 1); |
| 221 | + |
| 222 | + return matches; |
| 223 | +} |
| 224 | + |
| 225 | +// --------------------------------------------------------------------------- |
| 226 | +// Registration |
| 227 | +// --------------------------------------------------------------------------- |
| 228 | + |
| 229 | +let registered = false; |
| 230 | + |
| 231 | +export function registerGgsqlLinks(siteRoot: string): void { |
| 232 | + if (registered) return; |
| 233 | + registered = true; |
| 234 | + |
| 235 | + // Normalize siteRoot to end with / |
| 236 | + if (!siteRoot.endsWith("/")) siteRoot += "/"; |
| 237 | + |
| 238 | + // Resolve site root to an absolute URL for Monaco's opener |
| 239 | + const baseUrl = new URL(siteRoot, window.location.href).href; |
| 240 | + |
| 241 | + // --- Link Provider --- |
| 242 | + monaco.languages.registerLinkProvider("ggsql", { |
| 243 | + provideLinks(model) { |
| 244 | + const links: monaco.languages.ILink[] = []; |
| 245 | + const lineCount = model.getLineCount(); |
| 246 | + |
| 247 | + for (let lineNum = 1; lineNum <= lineCount; lineNum++) { |
| 248 | + const lineText = model.getLineContent(lineNum); |
| 249 | + const lineMatches = findLinksInLine(lineText); |
| 250 | + |
| 251 | + for (const lm of lineMatches) { |
| 252 | + links.push({ |
| 253 | + range: new monaco.Range( |
| 254 | + lineNum, |
| 255 | + lm.startCol, |
| 256 | + lineNum, |
| 257 | + lm.endCol, |
| 258 | + ), |
| 259 | + url: new URL(lm.link.url + ".html", baseUrl).href, |
| 260 | + tooltip: lm.link.label, |
| 261 | + }); |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + return { links }; |
| 266 | + }, |
| 267 | + }); |
| 268 | + |
| 269 | +} |
0 commit comments