feat: give column data types a dedicated syntax-highlight colour

Both Node::Ident and Word carried a highlight_override field, and
both were dead — the walker driver discarded the Ident's and
walk_word hardcoded Keyword. So column types (int, serial, …)
rendered identically to table/column names.

Wire both overrides through, and add a dedicated HighlightClass::Type
with its own theme colour (tok_type), distinct from keyword-purple
and identifier-teal. The three type Ident slots opt in, so canonical
types and the advanced-mode single-word SQL aliases (float, varchar,
…) render as types; the two-word `double precision` alias opts in via
a new Word::type_keyword constructor. ADR-0022 Amendment 4.
This commit is contained in:
claude@clouddev1
2026-05-29 22:07:18 +00:00
parent 46a31284c5
commit d20f765325
10 changed files with 195 additions and 13 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ use crate::dsl::command::{
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
@@ -1079,7 +1079,7 @@ const COL_SPEC_NODES: &[Node] = &[
source: IdentSource::Types,
role: "col_type",
validator: Some(TYPE_VALIDATOR),
highlight_override: None,
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
+21
View File
@@ -47,6 +47,12 @@ use crate::dsl::walker::outcome::MatchedPath;
pub enum HighlightClass {
Keyword,
Identifier,
/// Column data-type keyword (`int`, `serial`, `text`, …).
/// Distinct from `Keyword` and `Identifier` so learners can
/// tell "this is a type" from a clause keyword or a name they
/// invented (ADR-0022 Amendment 4). Assigned via a type slot's
/// `highlight_override`, not by byte shape.
Type,
Number,
String,
Punct,
@@ -191,6 +197,21 @@ impl Word {
}
}
/// A keyword that highlights as a column **type** rather than a
/// clause keyword (ADR-0022 Amendment 4). The one user today is
/// the two-word `double precision` SQL alias (ADR-0035 §3): it
/// is matched as keyword tokens, not an `IdentSource::Types`
/// `Ident`, so without this it would render keyword-coloured
/// while its single-word synonyms (`float`, `real`) render as
/// types.
pub const fn type_keyword(primary: &'static str) -> Self {
Self {
primary,
aliases: &[],
highlight_override: Some(HighlightClass::Type),
}
}
/// Case-insensitive match against the primary or any alias.
pub fn matches(&self, candidate: &str) -> bool {
if candidate.eq_ignore_ascii_case(self.primary) {
+3 -3
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HintMode, IdentSource, IdentValidator, Node, NumberValidator,
ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -50,7 +50,7 @@ pub const TYPE_SLOT: Node = Node::Ident {
source: IdentSource::Types,
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: None,
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
+6 -4
View File
@@ -24,7 +24,9 @@
//! `sql_insert::SQL_INSERT_SHAPE`, which starts at `INTO`).
use crate::dsl::grammar::sql_select::reject_internal_table;
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word, shared, sql_expr};
use crate::dsl::grammar::{
HighlightClass, IdentSource, Node, ValidationError, Word, shared, sql_expr,
};
use crate::dsl::types::Type;
static COMMA: Node = Node::Punct(',');
@@ -60,7 +62,7 @@ const SQL_TYPE_NAME: Node = Node::Ident {
source: IdentSource::Types,
role: "col_type",
validator: Some(validate_sql_type_name),
highlight_override: None,
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
@@ -86,8 +88,8 @@ const LENGTH_OPT: Node = Node::Optional(&Node::Seq(LENGTH_NODES));
// on its own (ADR-0035 §6.3). The builder maps the pair to
// `Type::Real`.
static DOUBLE_PRECISION_NODES: &[Node] = &[
Node::Word(Word::keyword("double")),
Node::Word(Word::keyword("precision")),
Node::Word(Word::type_keyword("double")),
Node::Word(Word::type_keyword("precision")),
];
static TYPE_WITH_LENGTH_NODES: &[Node] = &[SQL_TYPE_NAME, LENGTH_OPT];
static SQL_TYPE_CHOICES: &[Node] = &[
+11 -3
View File
@@ -180,7 +180,7 @@ fn walk_node_inner(
source: src,
role,
validator,
highlight_override: _,
highlight_override,
writes_table,
writes_column,
writes_user_listed_column,
@@ -193,6 +193,7 @@ fn walk_node_inner(
*src,
role,
*validator,
*highlight_override,
*writes_table,
*writes_column,
*writes_user_listed_column,
@@ -336,7 +337,10 @@ fn walk_word(
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Keyword,
// A keyword may opt into a non-default colour via
// `Word::type_keyword` (e.g. `double precision`, ADR-0022
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
} else {
@@ -397,6 +401,7 @@ fn walk_ident(
src: crate::dsl::grammar::IdentSource,
role: &'static str,
validator: Option<crate::dsl::grammar::IdentValidator>,
highlight_override: Option<crate::dsl::grammar::HighlightClass>,
writes_table: bool,
writes_column: bool,
writes_user_listed_column: bool,
@@ -554,7 +559,10 @@ fn walk_ident(
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Identifier,
// A type slot (and any future slot that wants a non-default
// colour) overrides the otherwise-uniform Identifier class
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
+70
View File
@@ -297,6 +297,76 @@ mod tests {
assert_eq!(runs[1].2, HighlightClass::Keyword);
}
#[test]
fn dsl_type_keyword_classified_as_type() {
// Issue #8 / ADR-0022 Amendment 4: the column type in a DSL
// `create table` (`serial`) is a Type, distinct from the
// identifiers `T` / `id` which stay Identifier.
let runs = run("create table T with pk id(serial)");
// `serial` occupies bytes 26..32 in the input.
let serial = runs
.iter()
.find(|(s, e, _)| &"create table T with pk id(serial)"[*s..*e] == "serial")
.expect("serial run present");
assert_eq!(serial.2, HighlightClass::Type);
// The invented identifiers are NOT typed.
for name in ["T", "id"] {
let r = runs
.iter()
.find(|(s, e, _)| &"create table T with pk id(serial)"[*s..*e] == name)
.unwrap_or_else(|| panic!("{name} run present"));
assert_eq!(r.2, HighlightClass::Identifier, "{name} stays Identifier");
}
}
#[test]
fn advanced_type_keywords_classified_as_type() {
// Issue #8: the exact example from the ticket. In advanced
// mode the SQL type keywords `int` / `serial` are Type;
// `Orders` / `count` / `id` stay Identifier.
let input = "create table Orders (count int, id serial)";
let runs: Vec<(usize, usize, HighlightClass)> =
highlight_runs_in_mode(input, crate::mode::Mode::Advanced)
.into_iter()
.map(|c| (c.start, c.end, c.class))
.collect();
let class_of = |needle: &str| {
runs.iter()
.find(|(s, e, _)| &input[*s..*e] == needle)
.unwrap_or_else(|| panic!("{needle} run present"))
.2
};
assert_eq!(class_of("int"), HighlightClass::Type);
assert_eq!(class_of("serial"), HighlightClass::Type);
assert_eq!(class_of("Orders"), HighlightClass::Identifier);
assert_eq!(class_of("count"), HighlightClass::Identifier);
assert_eq!(class_of("id"), HighlightClass::Identifier);
}
#[test]
fn advanced_double_precision_classified_as_type() {
// Issue #8 follow-up: the two-word `double precision` SQL
// alias (ADR-0035 §3) is matched as keyword tokens, but
// `Word::type_keyword` colours it as a type so it matches
// its single-word synonyms `float` / `real`. Both words
// carry the Type class.
let input = "create table T (x double precision)";
let runs: Vec<(usize, usize, HighlightClass)> =
highlight_runs_in_mode(input, crate::mode::Mode::Advanced)
.into_iter()
.map(|c| (c.start, c.end, c.class))
.collect();
let class_of = |needle: &str| {
runs.iter()
.find(|(s, e, _)| &input[*s..*e] == needle)
.unwrap_or_else(|| panic!("{needle} run present"))
.2
};
assert_eq!(class_of("double"), HighlightClass::Type);
assert_eq!(class_of("precision"), HighlightClass::Type);
assert_eq!(class_of("x"), HighlightClass::Identifier);
}
#[test]
fn full_command_walks_with_each_class() {
// `update T set Name='hi' --all-rows` — walker covers it
+22
View File
@@ -51,6 +51,11 @@ pub struct Theme {
// ---- Per-token-class colours (ADR-0022 §3) -------------------
pub tok_keyword: Color,
pub tok_identifier: Color,
/// Column data-type keyword colour (ADR-0022 Amendment 4) —
/// a dedicated tone distinct from both `tok_keyword` and
/// `tok_identifier` so a learner can tell a type from a
/// clause keyword or an invented name at a glance.
pub tok_type: Color,
pub tok_number: Color,
pub tok_string: Color,
pub tok_punct: Color,
@@ -84,6 +89,7 @@ impl Theme {
// distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
@@ -113,6 +119,7 @@ impl Theme {
// literals + flags; cool accent for keyword.
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
@@ -132,6 +139,7 @@ impl Theme {
match class {
HighlightClass::Keyword => self.tok_keyword,
HighlightClass::Identifier => self.tok_identifier,
HighlightClass::Type => self.tok_type,
HighlightClass::Number => self.tok_number,
HighlightClass::String => self.tok_string,
HighlightClass::Punct => self.tok_punct,
@@ -156,6 +164,7 @@ mod tests {
let t = Theme::dark();
for (name, c) in [
("tok_keyword", t.tok_keyword),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
@@ -174,6 +183,7 @@ mod tests {
let t = Theme::light();
for (name, c) in [
("tok_keyword", t.tok_keyword),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
@@ -192,10 +202,22 @@ mod tests {
let t = Theme::dark();
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword);
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier);
assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type);
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number);
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string);
assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct);
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag);
assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error);
}
#[test]
fn type_colour_is_distinct_from_keyword_and_identifier() {
// ADR-0022 Amendment 4 / issue #8: the whole point of a
// dedicated type class is that types do NOT share a colour
// with clause keywords or invented identifiers.
for t in [Theme::dark(), Theme::light()] {
assert_ne!(t.tok_type, t.tok_keyword);
assert_ne!(t.tok_type, t.tok_identifier);
}
}
}