diff --git a/dbml-homepage/docs/syntax/enrichment-visualization.md b/dbml-homepage/docs/syntax/enrichment-visualization.md index a584e7e0f..35b9b88fc 100644 --- a/dbml-homepage/docs/syntax/enrichment-visualization.md +++ b/dbml-homepage/docs/syntax/enrichment-visualization.md @@ -257,6 +257,20 @@ TableGroup e_commerce [color: #3498DB] { } ``` +### Sticky note color + +Use `color` on a sticky note to change its background color. Use `none` for transparent: + +```text +Note reminder [color: #F4D03F] { + 'This is a reminder' +} + +Note no_color [color: none] { + 'This note has no background color' +} +``` + ## Inactive Ref Use `inactive` on a relationship to mark it as inactive. Inactive refs are displayed as a dotted line in the diagram, allowing you to document relationships that are not of immediate focus. diff --git a/packages/dbml-core/src/export/DbmlExporter.ts b/packages/dbml-core/src/export/DbmlExporter.ts index 37fbd4cc4..02b40e1a9 100644 --- a/packages/dbml-core/src/export/DbmlExporter.ts +++ b/packages/dbml-core/src/export/DbmlExporter.ts @@ -3,6 +3,7 @@ import { addDoubleQuoteIfNeeded, formatRecordValue } from '@dbml/parse'; import { shouldPrintSchema } from './utils'; import { DEFAULT_SCHEMA_NAME } from '../model_structure/config'; import type { NormalizedModel, RecordValue } from '../../types/model_structure/database'; +import type { NormalizedNote } from '../../types/model_structure/stickyNote'; import type { NormalizedTable } from '../../types/model_structure/table'; import type { NormalizedTableGroup } from '../../types/model_structure/tableGroup'; @@ -362,10 +363,19 @@ class DbmlExporter { return tableGroupStrs.length ? tableGroupStrs.join('\n') : ''; } + static getStickyNoteSettings (note: NormalizedNote): string { + let settingStr = ''; + if (note.color) { + settingStr += `color: ${note.color}`; + } + return settingStr ? ` [${settingStr}]` : ''; + } + static exportStickyNotes (model: NormalizedModel): string { return reduce(model.notes, (result, note) => { const escapedContent = ` ${DbmlExporter.escapeNote(note.content)}`; - const stickyNote = `Note ${note.name} {\n${escapedContent}\n}\n`; + const settingStr = DbmlExporter.getStickyNoteSettings(note); + const stickyNote = `Note ${note.name}${settingStr} {\n${escapedContent}\n}\n`; // Add a blank line between note elements return result ? `${result}\n${stickyNote}` : stickyNote; diff --git a/packages/dbml-core/src/model_structure/stickyNote.js b/packages/dbml-core/src/model_structure/stickyNote.js index 743cd7500..885e541f0 100644 --- a/packages/dbml-core/src/model_structure/stickyNote.js +++ b/packages/dbml-core/src/model_structure/stickyNote.js @@ -5,15 +5,15 @@ class StickyNote extends Element { * @param {import('../../types/model_structure/stickyNote').RawStickyNote} param0 */ constructor ({ - name, content, headerColor, token, database = {}, + name, content, color, token, database = {}, } = {}) { super(token); /** @type {string} */ this.name = name; /** @type {string} */ this.content = content; - /** @type {string} */ - this.headerColor = headerColor; + /** @type {string | undefined} */ + this.color = color; /** @type {import('../../types/model_structure/database').default} */ this.database = database; /** @type {import('../../types/model_structure/dbState').default} */ @@ -30,7 +30,7 @@ class StickyNote extends Element { return { name: this.name, content: this.content, - headerColor: this.headerColor, + color: this.color, }; } diff --git a/packages/dbml-core/types/model_structure/stickyNote.d.ts b/packages/dbml-core/types/model_structure/stickyNote.d.ts index 0cdcbcaf9..33a4661ea 100644 --- a/packages/dbml-core/types/model_structure/stickyNote.d.ts +++ b/packages/dbml-core/types/model_structure/stickyNote.d.ts @@ -8,23 +8,23 @@ export interface RawStickyNote { content: string; database: Database; token: Token; - headerColor: string; + color?: string; } declare class StickyNote extends Element { name: string; content: string; noteToken: Token; - headerColor: string; + color?: string; database: Database; dbState: DbState; id: number; - constructor({ name, content, token, headerColor, database }: RawStickyNote); + constructor({ name, content, token, color, database }: RawStickyNote); generateId(): void; export(): { name: string; content: string; - headerColor: string; + color?: string; }; normalize(model: NormalizedModel): void; } @@ -32,7 +32,7 @@ export interface NormalizedNote { id: number; name: string; content: string; - headerColor: string | null; + color?: string; } export interface NormalizedNoteIdMap { diff --git a/packages/dbml-parse/__tests__/examples/interpreter/interpreter.test.ts b/packages/dbml-parse/__tests__/examples/interpreter/interpreter.test.ts index 5caba6dc6..f6c0154a2 100644 --- a/packages/dbml-parse/__tests__/examples/interpreter/interpreter.test.ts +++ b/packages/dbml-parse/__tests__/examples/interpreter/interpreter.test.ts @@ -2104,6 +2104,46 @@ describe('[example] interpreter', () => { // Should not crash expect(result.getValue()).toBeDefined(); }); + + test('should interpret sticky note with color', () => { + const source = ` + Note my_note [color: #FF5733] { + 'A colored note' + } + `; + const db = interpret(source).getValue()!; + expect(db.notes[0].color).toBe('#FF5733'); + }); + + test('should interpret sticky note with color none', () => { + const source = ` + Note my_note [color: none] { + 'A note without color' + } + `; + const db = interpret(source).getValue()!; + expect(db.notes[0].color).toBe('#00000000'); + }); + + test('should interpret sticky note with double-quoted content', () => { + const source = ` + Note my_note { + "A double-quoted note" + } + `; + const db = interpret(source).getValue()!; + expect(db.notes[0].content).toBe('A double-quoted note'); + }); + + test('should interpret sticky note without color setting', () => { + const source = ` + Note my_note { + 'A plain note' + } + `; + const db = interpret(source).getValue()!; + expect(db.notes[0].color).toBeUndefined(); + }); }); describe('multiple table verification', () => { diff --git a/packages/dbml-parse/__tests__/examples/validator/validator.test.ts b/packages/dbml-parse/__tests__/examples/validator/validator.test.ts index bf875508b..e793731d1 100644 --- a/packages/dbml-parse/__tests__/examples/validator/validator.test.ts +++ b/packages/dbml-parse/__tests__/examples/validator/validator.test.ts @@ -693,6 +693,64 @@ describe('[example] validator', () => { expect(errors).toHaveLength(0); }); + + test('should accept sticky note with color setting', () => { + const source = ` + Note my_note [color: #FF5733] { + 'A colored note' + } + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(0); + }); + + test('should accept sticky note with color none', () => { + const source = ` + Note my_note [color: none] { + 'A note without color' + } + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(0); + }); + + test('should reject unknown setting on sticky note', () => { + const source = ` + Note my_note [unknown: value] { + 'A note' + } + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe(CompileErrorCode.UNKNOWN_NOTE_SETTING); + }); + + test('should reject invalid color value on sticky note', () => { + const source = ` + Note my_note [color: invalid] { + 'A note' + } + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe(CompileErrorCode.INVALID_NOTE_SETTING_VALUE); + }); + + test('should reject duplicate color setting on sticky note', () => { + const source = ` + Note my_note [color: #FF5733, color: #00FF00] { + 'A note' + } + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(2); + expect(errors[0].code).toBe(CompileErrorCode.DUPLICATE_NOTE_SETTING); + }); }); describe('context validation', () => { diff --git a/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes.in.dbml b/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes.in.dbml index 3a79e1121..3c52137c0 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes.in.dbml +++ b/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes.in.dbml @@ -3,7 +3,7 @@ Table users [headercolor: #3498DB] { username varchar(255) [not null, unique] } -Note nodeName [headercolor: #3457DB] { +Note nodeName [color: #3457DB] { ''' Hello is that me you are looking for. ''' diff --git a/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes_validator.in.dbml b/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes_validator.in.dbml index 194691945..b159c62c2 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes_validator.in.dbml +++ b/packages/dbml-parse/__tests__/snapshots/binder/input/sticky_notes_validator.in.dbml @@ -32,7 +32,7 @@ Note "schema.note4" { ''' } -Note "schema"."note5" [headercolor: #3457DB] { +Note "schema"."note5" [color: #3457DB] { ''' # Title body diff --git a/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes.out.json b/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes.out.json index ec1157403..768d67822 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes.out.json +++ b/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes.out.json @@ -1,18 +1,4 @@ { - "errors": [ - { - "code": "UNEXPECTED_SETTINGS", - "diagnostic": "A Note shouldn't have a setting list", - "filepath": "/main.dbml", - "level": "error", - "node": { - "context": { - "id": "node@@@[L5:C14, L5:C36]", - "snippet": "[headercol...: #3457DB]" - } - } - } - ], "program": { "publicSchema": [ { diff --git a/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes_validator.out.json b/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes_validator.out.json index 831064da6..cb72fd469 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes_validator.out.json +++ b/packages/dbml-parse/__tests__/snapshots/binder/output/sticky_notes_validator.out.json @@ -35,18 +35,6 @@ "snippet": "Note schem...dy\n '''\n}" } } - }, - { - "code": "UNEXPECTED_SETTINGS", - "diagnostic": "A Note shouldn't have a setting list", - "filepath": "/main.dbml", - "level": "error", - "node": { - "context": { - "id": "node@@@[L34:C22, L34:C44]", - "snippet": "[headercol...: #3457DB]" - } - } } ], "program": { diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/input/sticky_notes.in.dbml b/packages/dbml-parse/__tests__/snapshots/interpreter/input/sticky_notes.in.dbml index 2c8de8c13..66938deed 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/input/sticky_notes.in.dbml +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/input/sticky_notes.in.dbml @@ -3,7 +3,7 @@ Table users [headercolor: #3498DB] { username varchar(255) [not null, unique] } -Note note { +Note note [color: #FF5733] { 'One line note' } @@ -13,3 +13,7 @@ Note note2 { body ''' } + +Note note3 [color: none] { + 'Note without color' +} diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/sticky_notes.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/sticky_notes.out.json index adb82ad78..837045798 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/sticky_notes.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/sticky_notes.out.json @@ -2,13 +2,14 @@ "database": { "notes": [ { + "color": "#FF5733", "content": "One line note", "name": "note", "token": { "end": { "column": 2, "line": 8, - "offset": 141 + "offset": 158 }, "filepath": "/main.dbml", "start": { @@ -25,13 +26,31 @@ "end": { "column": 2, "line": 15, - "offset": 190 + "offset": 207 }, "filepath": "/main.dbml", "start": { "column": 1, "line": 10, - "offset": 143 + "offset": 160 + } + } + }, + { + "color": "#00000000", + "content": "Note without color", + "name": "note3", + "token": { + "end": { + "column": 2, + "line": 19, + "offset": 260 + }, + "filepath": "/main.dbml", + "start": { + "column": 1, + "line": 17, + "offset": 209 } } } @@ -104,8 +123,8 @@ "token": { "end": { "column": 1, - "line": 16, - "offset": 191 + "line": 20, + "offset": 261 }, "filepath": "/main.dbml", "start": { diff --git a/packages/dbml-parse/src/core/global_modules/note/interpret.ts b/packages/dbml-parse/src/core/global_modules/note/interpret.ts index a6b06838a..d753b19a8 100644 --- a/packages/dbml-parse/src/core/global_modules/note/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/note/interpret.ts @@ -1,4 +1,4 @@ -import { get, partition } from 'lodash-es'; +import { partition } from 'lodash-es'; import { aggregateSettingList } from '@/core/utils/validate'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; import { @@ -14,6 +14,8 @@ import { getTokenPosition, normalizeNote, } from '@/core/utils/interpret'; +import { isExpressionAnIdentifierNode } from '@/core/utils/validate'; +import { extractQuotedStringToken } from '@/core/utils/expression'; import type { Filepath, NoteSymbol, @@ -64,7 +66,12 @@ export class StickyNoteInterpreter { private interpretSettingList (settings?: ListExpressionNode): CompileError[] { const settingMap = aggregateSettingList(settings).getValue(); - this.note.headerColor = settingMap.headercolor?.length ? extractColor(settingMap.headercolor?.at(0)?.value as any) : undefined; + if (settingMap.color?.length) { + const colorNode = settingMap.color.at(0)?.value; + const isNone = isExpressionAnIdentifierNode(colorNode) && colorNode.expression.variable.value.toLowerCase() === 'none'; + // Transparent color #00000000 + this.note.color = isNone ? '#00000000' : extractColor(colorNode as any); + } return []; } @@ -87,7 +94,7 @@ export class StickyNoteInterpreter { } private interpretNote (note: FunctionApplicationNode): CompileError[] { - const noteContent = get(note, 'callee.expression.literal.value', ''); + const noteContent = extractQuotedStringToken(note.callee) ?? ''; this.note.content = normalizeNote(noteContent); return []; diff --git a/packages/dbml-parse/src/core/local_modules/note/validate.ts b/packages/dbml-parse/src/core/local_modules/note/validate.ts index 4bb65badf..76109c634 100644 --- a/packages/dbml-parse/src/core/local_modules/note/validate.ts +++ b/packages/dbml-parse/src/core/local_modules/note/validate.ts @@ -1,11 +1,13 @@ -import { partition } from 'lodash-es'; +import { partition, forIn } from 'lodash-es'; import Compiler from '@/compiler'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; -import { ElementKind } from '@/core/types/keywords'; +import { ElementKind, SettingName } from '@/core/types/keywords'; import { BlockExpressionNode, ElementDeclarationNode, FunctionApplicationNode, ListExpressionNode, ProgramNode, SyntaxNode, } from '@/core/types/nodes'; -import { isExpressionAQuotedString } from '@/core/utils/validate'; +import { + aggregateSettingList, isExpressionAQuotedString, isValidColor, isExpressionAnIdentifierNode, +} from '@/core/utils/validate'; export default class NoteValidator { private compiler: Compiler; @@ -63,14 +65,48 @@ export default class NoteValidator { return []; } + // A sticky note is a Note that appears top-level + // A sticky note is not if it appear nested + private isStickyNote (): boolean { + return this.declarationNode.parent instanceof ProgramNode; + } + private validateSettingList (settingList?: ListExpressionNode): CompileError[] { - if (settingList) { + if (!settingList) return []; + + // Normal note (non-sticky) cannot have settings + if (!this.isStickyNote()) { return [ new CompileError(CompileErrorCode.UNEXPECTED_SETTINGS, 'A Note shouldn\'t have a setting list', settingList), ]; } - return []; + const aggReport = aggregateSettingList(settingList); + const errors = aggReport.getErrors(); + const settingMap = aggReport.getValue(); + + forIn(settingMap, (attrs, name) => { + switch (name) { + // Sticky note color + case SettingName.Color: + if (attrs.length > 1) { + errors.push(...attrs.map((attr) => new CompileError(CompileErrorCode.DUPLICATE_NOTE_SETTING, '\'color\' can only appear once', attr))); + } + attrs.forEach((attr) => { + // color can be `none` (transparent) + const isNoneKeyword = isExpressionAnIdentifierNode(attr.value) && attr.value.expression.variable.value.toLowerCase() === 'none'; + // color can be a hex number + if (!isValidColor(attr.value) && !isNoneKeyword) { + errors.push(new CompileError(CompileErrorCode.INVALID_NOTE_SETTING_VALUE, '\'color\' must be a color literal or \'none\'', attr.value || attr.name!)); + } + }); + break; + default: + errors.push(...attrs.map((attr) => new CompileError(CompileErrorCode.UNKNOWN_NOTE_SETTING, `Unknown '${name}' setting`, attr))); + } + }); + + return errors; } validateBody (body?: FunctionApplicationNode | BlockExpressionNode): CompileError[] { diff --git a/packages/dbml-parse/src/core/types/errors.ts b/packages/dbml-parse/src/core/types/errors.ts index 7131e965b..3e3f24872 100644 --- a/packages/dbml-parse/src/core/types/errors.ts +++ b/packages/dbml-parse/src/core/types/errors.ts @@ -71,6 +71,9 @@ export enum CompileErrorCode { NOTE_REDEFINED, NOTE_CONTENT_REDEFINED, EMPTY_NOTE, + UNKNOWN_NOTE_SETTING, + DUPLICATE_NOTE_SETTING, + INVALID_NOTE_SETTING_VALUE, INVALID_INDEXES_CONTEXT, INVALID_INDEXES_FIELD, diff --git a/packages/dbml-parse/src/core/types/schemaJson.ts b/packages/dbml-parse/src/core/types/schemaJson.ts index 982618ea7..737beefbd 100644 --- a/packages/dbml-parse/src/core/types/schemaJson.ts +++ b/packages/dbml-parse/src/core/types/schemaJson.ts @@ -98,7 +98,7 @@ export interface Note { name: string; content: string; token: TokenPosition; - headerColor?: string; + color?: string; } export interface ColumnType { diff --git a/packages/dbml-parse/src/services/monarch.ts b/packages/dbml-parse/src/services/monarch.ts index c158eb048..fec2864e2 100644 --- a/packages/dbml-parse/src/services/monarch.ts +++ b/packages/dbml-parse/src/services/monarch.ts @@ -193,6 +193,7 @@ const dbmlMonarchTokensProvider: MonarchLanguage = { 'name', 'as', 'color', + 'none', 'check', 'tables', 'tablegroups', diff --git a/packages/dbml-parse/src/services/suggestions/provider.ts b/packages/dbml-parse/src/services/suggestions/provider.ts index bac0179d1..04ff17c3f 100644 --- a/packages/dbml-parse/src/services/suggestions/provider.ts +++ b/packages/dbml-parse/src/services/suggestions/provider.ts @@ -467,6 +467,12 @@ function suggestAttributeName (compiler: Compiler, filepath: Filepath, offset: n ]; break; + case ScopeKind.NOTE: + attributes = [ + SettingName.Color, + ]; + break; + default: attributes = []; } @@ -635,6 +641,19 @@ function suggestAttributeValue ( range: undefined as any, })), }; + case 'color': + if (compiler.container.scopeKind(filepath, offset) === ScopeKind.NOTE) { + return { + suggestions: ['none'].map((name) => ({ + label: name, + insertText: name, + kind: CompletionItemKind.Value, + insertTextRules: CompletionItemInsertTextRule.KeepWhitespace, + range: undefined as any, + })), + }; + } + break; case 'default': return suggestNamesInScope(compiler, filepath, offset, compiler.container.element(filepath, offset), [ SymbolKind.Schema,