feat: add blu-site static site generator and fix language issues
Build a complete static site generator in Lux that faithfully clones
blu.cx (elmstatic). Generates 14 post pages, section indexes, tag pages,
and a home page with snippets grid from markdown content.
Language fixes discovered during development:
- Add \{ and \} escape sequences in string literals (lexer)
- Register String.indexOf and String.lastIndexOf in type checker
- Fix formatter to preserve brace escapes in string literals
- Improve LSP hover to show documentation for let bindings and functions
ISSUES.md documents 15 Lux language limitations found during the project.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -728,7 +728,7 @@ impl Formatter {
|
||||
match &lit.kind {
|
||||
LiteralKind::Int(n) => n.to_string(),
|
||||
LiteralKind::Float(f) => format!("{}", f),
|
||||
LiteralKind::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
|
||||
LiteralKind::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"").replace('{', "\\{").replace('}', "\\}")),
|
||||
LiteralKind::Char(c) => format!("'{}'", c),
|
||||
LiteralKind::Bool(b) => b.to_string(),
|
||||
LiteralKind::Unit => "()".to_string(),
|
||||
|
||||
@@ -493,6 +493,8 @@ impl<'a> Lexer<'a> {
|
||||
Some('"') => '"',
|
||||
Some('0') => '\0',
|
||||
Some('\'') => '\'',
|
||||
Some('{') => '{',
|
||||
Some('}') => '}',
|
||||
Some('x') => {
|
||||
// Hex escape \xNN
|
||||
let h1 = self.advance().and_then(|c| c.to_digit(16));
|
||||
|
||||
296
src/lsp.rs
296
src/lsp.rs
@@ -317,111 +317,227 @@ impl LspServer {
|
||||
let doc = self.documents.get(&uri)?;
|
||||
let source = &doc.text;
|
||||
|
||||
// Try to get info from symbol table first
|
||||
// Try to get info from symbol table first (position-based lookup)
|
||||
if let Some(ref table) = doc.symbol_table {
|
||||
let offset = self.position_to_offset(source, position);
|
||||
if let Some(symbol) = table.definition_at_position(offset) {
|
||||
let signature = symbol.type_signature.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(&symbol.name);
|
||||
let kind_str = match symbol.kind {
|
||||
SymbolKind::Function => "function",
|
||||
SymbolKind::Variable => "variable",
|
||||
SymbolKind::Parameter => "parameter",
|
||||
SymbolKind::Type => "type",
|
||||
SymbolKind::TypeParameter => "type parameter",
|
||||
SymbolKind::Variant => "variant",
|
||||
SymbolKind::Effect => "effect",
|
||||
SymbolKind::EffectOperation => "effect operation",
|
||||
SymbolKind::Field => "field",
|
||||
SymbolKind::Module => "module",
|
||||
};
|
||||
let doc_str = symbol.documentation.as_ref()
|
||||
.map(|d| format!("\n\n{}", d))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Format signature: wrap long signatures onto multiple lines
|
||||
let formatted_sig = format_signature_for_hover(signature);
|
||||
|
||||
// Add behavioral property documentation if present
|
||||
let property_docs = extract_property_docs(signature);
|
||||
|
||||
return Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!("```lux\n{}\n```\n\n*{}*{}{}", formatted_sig, kind_str, property_docs, doc_str),
|
||||
}),
|
||||
range: None,
|
||||
});
|
||||
return Some(self.format_symbol_hover(symbol));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back: try looking up the declaration name when hovering on keywords
|
||||
// Get the word under cursor
|
||||
let word = self.get_word_at_position(source, position)?;
|
||||
|
||||
// When hovering on a keyword like 'fn', 'type', 'effect', 'let', 'trait',
|
||||
// look ahead to find the declaration name and show that symbol's info
|
||||
if let Some(ref table) = doc.symbol_table {
|
||||
let decl_name = match word.as_str() {
|
||||
"fn" | "type" | "effect" | "let" | "trait" | "handler" | "impl" => {
|
||||
let offset = self.position_to_offset(source, position);
|
||||
self.find_next_ident(source, offset + word.len())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = decl_name {
|
||||
// Look up the declaration name in the symbol table
|
||||
for sym in table.global_symbols() {
|
||||
if sym.name == name {
|
||||
let signature = sym.type_signature.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(&sym.name);
|
||||
let kind_str = match sym.kind {
|
||||
SymbolKind::Function => "function",
|
||||
SymbolKind::Variable => "variable",
|
||||
SymbolKind::Parameter => "parameter",
|
||||
SymbolKind::Type => "type",
|
||||
SymbolKind::TypeParameter => "type parameter",
|
||||
SymbolKind::Variant => "variant",
|
||||
SymbolKind::Effect => "effect",
|
||||
SymbolKind::EffectOperation => "effect operation",
|
||||
SymbolKind::Field => "field",
|
||||
SymbolKind::Module => "module",
|
||||
};
|
||||
let doc_str = sym.documentation.as_ref()
|
||||
.map(|d| format!("\n\n{}", d))
|
||||
.unwrap_or_default();
|
||||
let formatted_sig = format_signature_for_hover(signature);
|
||||
let property_docs = extract_property_docs(signature);
|
||||
|
||||
return Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!("```lux\n{}\n```\n\n*{}*{}{}", formatted_sig, kind_str, property_docs, doc_str),
|
||||
}),
|
||||
range: None,
|
||||
});
|
||||
if matches!(word.as_str(), "fn" | "type" | "effect" | "let" | "trait" | "handler" | "impl") {
|
||||
let offset = self.position_to_offset(source, position);
|
||||
if let Some(name) = self.find_next_ident(source, offset + word.len()) {
|
||||
for sym in table.global_symbols() {
|
||||
if sym.name == name {
|
||||
return Some(self.format_symbol_hover(sym));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try name-based lookup in symbol table (for usage sites)
|
||||
for sym in table.global_symbols() {
|
||||
if sym.name == word {
|
||||
return Some(self.format_symbol_hover(sym));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: rich documentation for keywords
|
||||
let info = self.get_rich_symbol_info(&word)
|
||||
.or_else(|| self.get_symbol_info(&word).map(|(s, d)| (s.to_string(), d.to_string())));
|
||||
// Check for module names (Console, List, String, etc.)
|
||||
if let Some(hover) = self.get_module_hover(&word) {
|
||||
return Some(hover);
|
||||
}
|
||||
|
||||
if let Some((signature, doc)) = info {
|
||||
let formatted_sig = format_signature_for_hover(&signature);
|
||||
Some(Hover {
|
||||
// Rich documentation for behavioral property keywords
|
||||
if let Some((signature, doc_text)) = self.get_rich_symbol_info(&word) {
|
||||
return Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!("```lux\n{}\n```\n\n{}", formatted_sig, doc),
|
||||
value: format!("```lux\n{}\n```\n\n{}", signature, doc_text),
|
||||
}),
|
||||
range: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
});
|
||||
}
|
||||
|
||||
// Builtin keyword/function info
|
||||
if let Some((signature, doc_text)) = self.get_symbol_info(&word) {
|
||||
return Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!("```lux\n{}\n```\n\n{}", signature, doc_text),
|
||||
}),
|
||||
range: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Format a symbol into a hover response
|
||||
fn format_symbol_hover(&self, symbol: &crate::symbol_table::Symbol) -> Hover {
|
||||
let signature = symbol.type_signature.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(&symbol.name);
|
||||
let kind_str = match symbol.kind {
|
||||
SymbolKind::Function => "function",
|
||||
SymbolKind::Variable => "variable",
|
||||
SymbolKind::Parameter => "parameter",
|
||||
SymbolKind::Type => "type",
|
||||
SymbolKind::TypeParameter => "type parameter",
|
||||
SymbolKind::Variant => "variant",
|
||||
SymbolKind::Effect => "effect",
|
||||
SymbolKind::EffectOperation => "effect operation",
|
||||
SymbolKind::Field => "field",
|
||||
SymbolKind::Module => "module",
|
||||
};
|
||||
let doc_str = symbol.documentation.as_ref()
|
||||
.map(|d| format!("\n\n{}", d))
|
||||
.unwrap_or_default();
|
||||
let formatted_sig = format_signature_for_hover(signature);
|
||||
let property_docs = extract_property_docs(signature);
|
||||
|
||||
Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!(
|
||||
"```lux\n{}\n```\n*{}*{}{}",
|
||||
formatted_sig, kind_str, property_docs, doc_str
|
||||
),
|
||||
}),
|
||||
range: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get hover info for built-in module names
|
||||
fn get_module_hover(&self, name: &str) -> Option<Hover> {
|
||||
let (sig, doc) = match name {
|
||||
"Console" => (
|
||||
"effect Console",
|
||||
"**Console I/O**\n\n\
|
||||
- `Console.print(msg: String): Unit` — print to stdout\n\
|
||||
- `Console.readLine(): String` — read a line from stdin\n\
|
||||
- `Console.readInt(): Int` — read an integer from stdin",
|
||||
),
|
||||
"File" => (
|
||||
"effect File",
|
||||
"**File System**\n\n\
|
||||
- `File.read(path: String): String` — read file contents\n\
|
||||
- `File.write(path: String, content: String): Unit` — write to file\n\
|
||||
- `File.append(path: String, content: String): Unit` — append to file\n\
|
||||
- `File.exists(path: String): Bool` — check if file exists\n\
|
||||
- `File.delete(path: String): Unit` — delete a file\n\
|
||||
- `File.list(path: String): List<String>` — list directory",
|
||||
),
|
||||
"Http" => (
|
||||
"effect Http",
|
||||
"**HTTP Client**\n\n\
|
||||
- `Http.get(url: String): String` — GET request\n\
|
||||
- `Http.post(url: String, body: String): String` — POST request\n\
|
||||
- `Http.put(url: String, body: String): String` — PUT request\n\
|
||||
- `Http.delete(url: String): String` — DELETE request",
|
||||
),
|
||||
"Sql" => (
|
||||
"effect Sql",
|
||||
"**SQL Database**\n\n\
|
||||
- `Sql.open(path: String): Connection` — open database\n\
|
||||
- `Sql.execute(conn: Connection, sql: String): Unit` — execute SQL\n\
|
||||
- `Sql.query(conn: Connection, sql: String): List<Row>` — query rows\n\
|
||||
- `Sql.close(conn: Connection): Unit` — close connection",
|
||||
),
|
||||
"Random" => (
|
||||
"effect Random",
|
||||
"**Random Number Generation**\n\n\
|
||||
- `Random.int(min: Int, max: Int): Int` — random integer\n\
|
||||
- `Random.float(): Float` — random float 0.0–1.0\n\
|
||||
- `Random.bool(): Bool` — random boolean",
|
||||
),
|
||||
"Time" => (
|
||||
"effect Time",
|
||||
"**Time**\n\n\
|
||||
- `Time.now(): Int` — current Unix timestamp (ms)\n\
|
||||
- `Time.sleep(ms: Int): Unit` — sleep for milliseconds",
|
||||
),
|
||||
"Process" => (
|
||||
"effect Process",
|
||||
"**Process / System**\n\n\
|
||||
- `Process.exec(cmd: String): String` — run shell command\n\
|
||||
- `Process.env(name: String): String` — get env variable\n\
|
||||
- `Process.args(): List<String>` — command-line arguments\n\
|
||||
- `Process.exit(code: Int): Unit` — exit with code",
|
||||
),
|
||||
"Math" => (
|
||||
"module Math",
|
||||
"**Math Functions**\n\n\
|
||||
- `Math.abs(n: Int): Int` — absolute value\n\
|
||||
- `Math.min(a: Int, b: Int): Int` — minimum\n\
|
||||
- `Math.max(a: Int, b: Int): Int` — maximum\n\
|
||||
- `Math.sqrt(n: Float): Float` — square root\n\
|
||||
- `Math.pow(base: Float, exp: Float): Float` — power\n\
|
||||
- `Math.floor(n: Float): Int` — round down\n\
|
||||
- `Math.ceil(n: Float): Int` — round up",
|
||||
),
|
||||
"List" => (
|
||||
"module List",
|
||||
"**List Operations**\n\n\
|
||||
- `List.map(list, f)` — transform each element\n\
|
||||
- `List.filter(list, p)` — keep matching elements\n\
|
||||
- `List.fold(list, init, f)` — reduce to single value\n\
|
||||
- `List.head(list)` — first element (Option)\n\
|
||||
- `List.tail(list)` — all except first (Option)\n\
|
||||
- `List.length(list)` — number of elements\n\
|
||||
- `List.concat(a, b)` — concatenate lists\n\
|
||||
- `List.range(start, end)` — integer range\n\
|
||||
- `List.reverse(list)` — reverse order\n\
|
||||
- `List.get(list, i)` — element at index (Option)",
|
||||
),
|
||||
"String" => (
|
||||
"module String",
|
||||
"**String Operations**\n\n\
|
||||
- `String.length(s)` — string length\n\
|
||||
- `String.split(s, delim)` — split by delimiter\n\
|
||||
- `String.join(list, delim)` — join with delimiter\n\
|
||||
- `String.trim(s)` — trim whitespace\n\
|
||||
- `String.contains(s, sub)` — check substring\n\
|
||||
- `String.replace(s, from, to)` — replace occurrences\n\
|
||||
- `String.startsWith(s, prefix)` — check prefix\n\
|
||||
- `String.endsWith(s, suffix)` — check suffix\n\
|
||||
- `String.substring(s, start, end)` — extract range\n\
|
||||
- `String.chars(s)` — list of characters",
|
||||
),
|
||||
"Option" => (
|
||||
"type Option<A> = Some(A) | None",
|
||||
"**Optional Value**\n\n\
|
||||
- `Option.isSome(opt)` — has a value?\n\
|
||||
- `Option.isNone(opt)` — is empty?\n\
|
||||
- `Option.getOrElse(opt, default)` — unwrap or default\n\
|
||||
- `Option.map(opt, f)` — transform if present\n\
|
||||
- `Option.flatMap(opt, f)` — chain operations",
|
||||
),
|
||||
"Result" => (
|
||||
"type Result<A, E> = Ok(A) | Err(E)",
|
||||
"**Result of Fallible Operation**\n\n\
|
||||
- `Result.isOk(r)` — succeeded?\n\
|
||||
- `Result.isErr(r)` — failed?\n\
|
||||
- `Result.map(r, f)` — transform success value\n\
|
||||
- `Result.mapErr(r, f)` — transform error value",
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!("```lux\n{}\n```\n{}", sig, doc),
|
||||
}),
|
||||
range: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_word_at_position(&self, source: &str, position: Position) -> Option<String> {
|
||||
@@ -672,17 +788,11 @@ impl LspServer {
|
||||
|
||||
fn position_to_offset(&self, source: &str, position: Position) -> usize {
|
||||
let mut offset = 0;
|
||||
let mut line = 0u32;
|
||||
|
||||
for (i, c) in source.char_indices() {
|
||||
if line == position.line {
|
||||
let col = i - offset;
|
||||
return offset + (position.character as usize).min(col + 1);
|
||||
}
|
||||
if c == '\n' {
|
||||
line += 1;
|
||||
offset = i + 1;
|
||||
for (line_idx, line) in source.lines().enumerate() {
|
||||
if line_idx == position.line as usize {
|
||||
return offset + (position.character as usize).min(line.len());
|
||||
}
|
||||
offset += line.len() + 1; // +1 for newline
|
||||
}
|
||||
source.len()
|
||||
}
|
||||
|
||||
@@ -228,13 +228,14 @@ impl SymbolTable {
|
||||
Declaration::Let(let_decl) => {
|
||||
let is_public = matches!(let_decl.visibility, Visibility::Public);
|
||||
let type_sig = let_decl.typ.as_ref().map(|t| self.type_expr_to_string(t));
|
||||
let symbol = self.new_symbol(
|
||||
let mut symbol = self.new_symbol(
|
||||
let_decl.name.name.clone(),
|
||||
SymbolKind::Variable,
|
||||
let_decl.span,
|
||||
type_sig,
|
||||
is_public,
|
||||
);
|
||||
symbol.documentation = let_decl.doc.clone();
|
||||
let id = self.add_symbol(scope_idx, symbol);
|
||||
self.add_reference(id, let_decl.name.span, true, true);
|
||||
|
||||
@@ -279,13 +280,14 @@ impl SymbolTable {
|
||||
};
|
||||
let type_sig = format!("fn {}({}): {}{}{}", f.name.name, param_types.join(", "), return_type, properties, effects);
|
||||
|
||||
let symbol = self.new_symbol(
|
||||
let mut symbol = self.new_symbol(
|
||||
f.name.name.clone(),
|
||||
SymbolKind::Function,
|
||||
f.name.span,
|
||||
Some(type_sig),
|
||||
is_public,
|
||||
);
|
||||
symbol.documentation = f.doc.clone();
|
||||
let fn_id = self.add_symbol(scope_idx, symbol);
|
||||
self.add_reference(fn_id, f.name.span, true, false);
|
||||
|
||||
@@ -326,13 +328,14 @@ impl SymbolTable {
|
||||
let is_public = matches!(t.visibility, Visibility::Public);
|
||||
let type_sig = format!("type {}", t.name.name);
|
||||
|
||||
let symbol = self.new_symbol(
|
||||
let mut symbol = self.new_symbol(
|
||||
t.name.name.clone(),
|
||||
SymbolKind::Type,
|
||||
t.name.span,
|
||||
Some(type_sig),
|
||||
is_public,
|
||||
);
|
||||
symbol.documentation = t.doc.clone();
|
||||
let type_id = self.add_symbol(scope_idx, symbol);
|
||||
self.add_reference(type_id, t.name.span, true, false);
|
||||
|
||||
@@ -372,13 +375,14 @@ impl SymbolTable {
|
||||
let is_public = true; // Effects are typically public
|
||||
let type_sig = format!("effect {}", e.name.name);
|
||||
|
||||
let symbol = self.new_symbol(
|
||||
let mut symbol = self.new_symbol(
|
||||
e.name.name.clone(),
|
||||
SymbolKind::Effect,
|
||||
e.name.span,
|
||||
Some(type_sig),
|
||||
is_public,
|
||||
);
|
||||
symbol.documentation = e.doc.clone();
|
||||
let effect_id = self.add_symbol(scope_idx, symbol);
|
||||
|
||||
// Add operations
|
||||
@@ -409,13 +413,14 @@ impl SymbolTable {
|
||||
let is_public = matches!(t.visibility, Visibility::Public);
|
||||
let type_sig = format!("trait {}", t.name.name);
|
||||
|
||||
let symbol = self.new_symbol(
|
||||
let mut symbol = self.new_symbol(
|
||||
t.name.name.clone(),
|
||||
SymbolKind::Type, // Traits are like types
|
||||
t.name.span,
|
||||
Some(type_sig),
|
||||
is_public,
|
||||
);
|
||||
symbol.documentation = t.doc.clone();
|
||||
self.add_symbol(scope_idx, symbol);
|
||||
}
|
||||
|
||||
|
||||
@@ -1599,6 +1599,14 @@ impl TypeEnv {
|
||||
"parseFloat".to_string(),
|
||||
Type::function(vec![Type::String], Type::Option(Box::new(Type::Float))),
|
||||
),
|
||||
(
|
||||
"indexOf".to_string(),
|
||||
Type::function(vec![Type::String, Type::String], Type::Option(Box::new(Type::Int))),
|
||||
),
|
||||
(
|
||||
"lastIndexOf".to_string(),
|
||||
Type::function(vec![Type::String, Type::String], Type::Option(Box::new(Type::Int))),
|
||||
),
|
||||
]);
|
||||
env.bind("String", TypeScheme::mono(string_module_type));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user