Make ltx cli nicer

This commit is contained in:
2026-08-06 19:22:44 -04:00
parent a56450efc8
commit 772f135180
2 changed files with 245 additions and 143 deletions
+164 -143
View File
@@ -9,102 +9,114 @@ import "core:unicode"
// TODO(Paul): change the key-value format to allow for true arbitrary string values with the use of quotes (maybe?) and make the rules clean and easy to remember. // TODO(Paul): change the key-value format to allow for true arbitrary string values with the use of quotes (maybe?) and make the rules clean and easy to remember.
main :: proc() { main :: proc() {
if len(os.args) < 2 { args := parse_args()
fmt.eprintf( if args.type == .None do return
"A naive implementation of TeX-based arbitrary markup. Prints syntax tree and space-preserving XML substitution\nUsage:\n\t%s <filepath>\n",
os.args[0],
)
return
}
ltx: Ltx ltx: Ltx
parse_err := ltx_parse_file(&ltx, os.args[1]) parse_err := parse_file(&ltx, args.file_path)
defer ltx_free_file(&ltx) defer free_file(&ltx)
if parse_err != .None { if parse_err != .None {
fmt.eprintln(ltx_get_error(&ltx, parse_err, context.temp_allocator)) fmt.eprintln(get_error(&ltx, parse_err, context.temp_allocator))
return return
} }
for node in ltx.nodes {
print_node(node)
}
sb := strings.builder_make() sb := strings.builder_make()
defer strings.builder_destroy(&sb) defer strings.builder_destroy(&sb)
fmt.println("--- BEGIN XML ---") switch args.type {
strings.builder_reset(&sb) case .None:
ltx_to_xml(&sb, ltx.nodes) return
case .XML:
to_xml(&sb, ltx.nodes)
case .PlainText:
to_plain_text(&sb, ltx.nodes)
case .SmartIndentedText:
to_text_with_smart_indent(&sb, ltx.nodes)
}
fmt.println(strings.to_string(sb)) fmt.println(strings.to_string(sb))
fmt.println("--- END XML ---")
fmt.println("--- BEGIN PLAIN TEXT ---") // for node in ltx.nodes {
strings.builder_reset(&sb) // print_node(node)
ltx_to_text_with_smart_indent(&sb, ltx.nodes) // }
fmt.println(strings.to_string(sb))
fmt.println("--- END PLAIN TEXT ---") // sb := strings.builder_make()
// defer strings.builder_destroy(&sb)
// fmt.println("--- BEGIN XML ---")
// strings.builder_reset(&sb)
// to_xml(&sb, ltx.nodes)
// fmt.println(strings.to_string(sb))
// fmt.println("--- END XML ---")
// fmt.println("--- BEGIN PLAIN TEXT ---")
// strings.builder_reset(&sb)
// to_text_with_smart_indent(&sb, ltx.nodes)
// fmt.println(strings.to_string(sb))
// fmt.println("--- END PLAIN TEXT ---")
} }
Tokens :: enum { print_help :: proc() {
Backslash, fmt.eprintln(
LeftBrace, "A naive implementation of TeX-based arbitrary markup. Prints syntax tree and space-preserving XML substitution",
RightBrace, )
LeftBracket, fmt.eprintln("Usage:")
RightBracket, fmt.eprintf("\t%s <filepath>\n", os.args[0])
Assign,
Quote,
} }
TokenArray := [Tokens]rune { parse_args :: proc() -> Cli_Args {
.Assign = '=', args := Cli_Args{}
.Backslash = '\\',
.LeftBrace = '{', if len(os.args) < 2 {
.RightBrace = '}', print_help()
.LeftBracket = '[', return args
.RightBracket = ']', }
.Quote = '"',
output_flags_used := 0
output_type := Cli_Output_Type.None
file_path: string
for arg in os.args[1:] {
if strings.starts_with(arg, "--") {
output_flags_used += 1
switch arg[2:] {
case "xml":
output_type = .XML
case "text":
output_type = .PlainText
case "smart":
output_type = .SmartIndentedText
case:
fmt.eprintln("Unknown flag:", arg)
print_help()
return args
}
} else {
if file_path != "" {
fmt.eprintln("Unknown argument:", arg)
print_help()
return args
}
file_path = arg
}
}
if output_flags_used > 1 {
fmt.eprintln("Too many flags passed")
print_help()
} else {
args.file_path = file_path
args.type = output_flags_used == 0 ? .PlainText : output_type
}
return args
} }
Node_Kind :: enum { seek :: proc(ltx: ^Ltx) -> (rune, Ltx_Error) {
Text,
Tag,
}
Attr_Type :: enum {
Flag,
Attribute,
}
Field :: struct {
value: string,
type: Attr_Type,
}
Node :: struct {
name: string,
kind: Node_Kind,
text: string,
attributes: map[string]Field,
children: [dynamic]Node,
}
Ltx_Error :: enum u32 {
None = 0,
EOF,
KeyExpected,
ClosingBracketExpected,
ClosingBraceExpected,
UnexpectedRightBrace,
ValueExpected,
KeyAlreadyExists,
InvalidKey,
CannotReadFile,
// InvalidKeyStart,
}
ltx_seek :: proc(ltx: ^Ltx) -> (rune, Ltx_Error) {
next_idx := ltx.idx + 1 next_idx := ltx.idx + 1
if next_idx >= len(ltx.source) do return 0, .EOF if next_idx >= len(ltx.source) do return 0, .EOF
if ltx_current_char(ltx) == '\n' { if current_char(ltx) == '\n' {
ltx.pos = { ltx.pos = {
col = 0, col = 0,
line = ltx.pos.line + 1, line = ltx.pos.line + 1,
@@ -113,26 +125,26 @@ ltx_seek :: proc(ltx: ^Ltx) -> (rune, Ltx_Error) {
ltx.pos.col += 1 ltx.pos.col += 1
} }
ltx.idx = next_idx ltx.idx = next_idx
c := ltx_current_char(ltx) c := current_char(ltx)
return c, .None return c, .None
} }
ltx_has_next :: proc(ltx: ^Ltx) -> b32 { has_next :: proc(ltx: ^Ltx) -> b32 {
return len(ltx.source) > ltx.idx + 1 return len(ltx.source) > ltx.idx + 1
} }
ltx_peek :: proc(ltx: ^Ltx) -> (rune, Ltx_Error) { peek :: proc(ltx: ^Ltx) -> (rune, Ltx_Error) {
if !ltx_has_next(ltx) do return 0, .EOF if !has_next(ltx) do return 0, .EOF
return rune(ltx.source[ltx.idx + 1]), .None return rune(ltx.source[ltx.idx + 1]), .None
} }
ltx_current_char :: proc(ltx: ^Ltx) -> rune { current_char :: proc(ltx: ^Ltx) -> rune {
assert(ltx.idx < len(ltx.source), "index cannot be greater than string length") assert(ltx.idx < len(ltx.source), "index cannot be greater than string length")
return rune(ltx.source[ltx.idx]) return rune(ltx.source[ltx.idx])
} }
ltx_consume_whitespace :: proc(ltx: ^Ltx) -> Ltx_Error { consume_whitespace :: proc(ltx: ^Ltx) -> Ltx_Error {
for unicode.is_white_space(ltx_current_char(ltx)) do ltx_seek(ltx) or_return // TODO: do prop error handling for unicode.is_white_space(current_char(ltx)) do seek(ltx) or_return // TODO: do prop error handling
return .None return .None
} }
@@ -145,25 +157,14 @@ validate_key :: proc(key: string) -> Ltx_Error {
return .None return .None
} }
Ltx :: struct { parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
source: string,
source_path: string,
nodes: [dynamic]Node,
idx: int,
pos: struct {
line: u32,
col: u32,
},
}
ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
ltx.idx = 0 ltx.idx = 0
stack := make([dynamic]Node) stack := make([dynamic]Node)
defer delete(stack) defer delete(stack)
text_node_start_idx := 0 text_node_start_idx := 0
for ltx_has_next(ltx) { for has_next(ltx) {
if ltx_current_char(ltx) == TokenArray[.Backslash] { if current_char(ltx) == TokenArray[.Backslash] {
// TODO: cehck if the char after slash is a token // TODO: cehck if the char after slash is a token
if text_node_start_idx < ltx.idx { if text_node_start_idx < ltx.idx {
node := Node { node := Node {
@@ -173,10 +174,10 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
if len(stack) == 0 do append(&ltx.nodes, node) if len(stack) == 0 do append(&ltx.nodes, node)
else do append(&stack[len(stack) - 1].children, node) else do append(&stack[len(stack) - 1].children, node)
} }
ltx_seek(ltx) or_return // skip \ seek(ltx) or_return // skip \
tag_start_idx := ltx.idx tag_start_idx := ltx.idx
for unicode.is_letter(ltx_current_char(ltx)) do ltx_seek(ltx) or_return for unicode.is_letter(current_char(ltx)) do seek(ltx) or_return
assert(tag_start_idx < ltx.idx) assert(tag_start_idx < ltx.idx)
tag_name := ltx.source[tag_start_idx:ltx.idx] tag_name := ltx.source[tag_start_idx:ltx.idx]
node := Node { node := Node {
@@ -193,18 +194,18 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
content = 0, content = 0,
} }
ltx_consume_whitespace(ltx) or_return consume_whitespace(ltx) or_return
for ltx_current_char(ltx) == TokenArray[.LeftBracket] { for current_char(ltx) == TokenArray[.LeftBracket] {
c := ltx_seek(ltx) or_return c := seek(ltx) or_return
field_start := ltx.idx field_start := ltx.idx
key: string key: string
for ltx_has_next(ltx) { for has_next(ltx) {
if c == TokenArray[.Assign] { if c == TokenArray[.Assign] {
if field_start >= ltx.idx do return .KeyExpected if field_start >= ltx.idx do return .KeyExpected
key = strings.trim(ltx.source[field_start:ltx.idx], " \t") key = strings.trim(ltx.source[field_start:ltx.idx], " \t")
if len(key) <= 0 do return .KeyExpected if len(key) <= 0 do return .KeyExpected
validate_key(key) or_return validate_key(key) or_return
ltx_seek(ltx) or_return seek(ltx) or_return
field_start = ltx.idx field_start = ltx.idx
} else if c == TokenArray[.RightBracket] { } else if c == TokenArray[.RightBracket] {
if field_start >= ltx.idx do return .ValueExpected if field_start >= ltx.idx do return .ValueExpected
@@ -220,17 +221,17 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
} }
_, _, found := map_upsert(&node.attributes, key, value) _, _, found := map_upsert(&node.attributes, key, value)
if found do return .KeyAlreadyExists if found do return .KeyAlreadyExists
ltx_seek(ltx) or_return seek(ltx) or_return
end_pos.fields = ltx.idx end_pos.fields = ltx.idx
break break
} }
c = ltx_seek(ltx) or_return c = seek(ltx) or_return
} }
ltx_consume_whitespace(ltx) or_return consume_whitespace(ltx) or_return
} }
ltx_consume_whitespace(ltx) or_return consume_whitespace(ltx) or_return
if ltx_current_char(ltx) == TokenArray[.LeftBrace] { if current_char(ltx) == TokenArray[.LeftBrace] {
ltx_seek(ltx) or_return // consume { seek(ltx) or_return // consume {
append(&stack, node) append(&stack, node)
end_pos.content = ltx.idx end_pos.content = ltx.idx
} else { } else {
@@ -239,7 +240,7 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
if end_pos.content != 0 do text_node_start_idx = end_pos.content if end_pos.content != 0 do text_node_start_idx = end_pos.content
else if end_pos.fields != 0 do text_node_start_idx = end_pos.fields else if end_pos.fields != 0 do text_node_start_idx = end_pos.fields
else do text_node_start_idx = end_pos.tag_name else do text_node_start_idx = end_pos.tag_name
} else if ltx_current_char(ltx) == TokenArray[.RightBrace] { } else if current_char(ltx) == TokenArray[.RightBrace] {
if len(stack) <= 0 do return .UnexpectedRightBrace if len(stack) <= 0 do return .UnexpectedRightBrace
node := pop(&stack) node := pop(&stack)
if text_node_start_idx < ltx.idx { if text_node_start_idx < ltx.idx {
@@ -254,10 +255,10 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
} else { } else {
append(&ltx.nodes, node) append(&ltx.nodes, node)
} }
ltx_seek(ltx) or_return // consume } seek(ltx) or_return // consume }
text_node_start_idx = ltx.idx text_node_start_idx = ltx.idx
} else { } else {
ltx_seek(ltx) or_return seek(ltx) or_return
} }
} }
if text_node_start_idx < ltx.idx { if text_node_start_idx < ltx.idx {
@@ -267,17 +268,17 @@ ltx_parse :: proc(ltx: ^Ltx) -> (err: Ltx_Error) {
return .None return .None
} }
ltx_parse_file :: proc(ltx: ^Ltx, file_path: string) -> Ltx_Error { parse_file :: proc(ltx: ^Ltx, file_path: string, allocator := context.allocator) -> Ltx_Error {
source, err := os.read_entire_file_from_path(file_path, context.allocator) source, err := os.read_entire_file_from_path(file_path, allocator)
if err != nil do return .CannotReadFile if err != nil do return .CannotReadFile
abs_path, abs_err := filepath.abs(file_path) abs_path, abs_err := filepath.abs(file_path)
if abs_err != nil do return .CannotReadFile if abs_err != nil do return .CannotReadFile
ltx.source_path = abs_path ltx.source_path = abs_path
ltx.source = string(source) ltx.source = string(source)
return ltx_parse(ltx) return parse(ltx)
} }
ltx_free_file :: proc(ltx: ^Ltx) -> runtime.Allocator_Error { free_file :: proc(ltx: ^Ltx) -> runtime.Allocator_Error {
delete(ltx.source) or_return delete(ltx.source) or_return
delete(ltx.source_path) or_return delete(ltx.source_path) or_return
return .None return .None
@@ -314,7 +315,7 @@ print_node :: proc(node: Node, indent_level := 0) {
} }
} }
ltx_to_text_with_smart_indent :: proc (sb: ^strings.Builder, nodes: [dynamic]Node, indent_level := 0) { to_text_with_smart_indent :: proc(sb: ^strings.Builder, nodes: [dynamic]Node, indent_level := 0) {
should_inc_indent_level := true should_inc_indent_level := true
for node in nodes { for node in nodes {
if node.kind == .Text { if node.kind == .Text {
@@ -326,32 +327,40 @@ ltx_to_text_with_smart_indent :: proc (sb: ^strings.Builder, nodes: [dynamic]Nod
for node in nodes { for node in nodes {
switch node.kind { switch node.kind {
case .Text: case .Text:
for i in 0..<indent_level-1 do strings.write_rune(sb, ' ') for i in 0 ..< indent_level - 1 do strings.write_rune(sb, ' ')
text := strings.trim(process_white_space(node.text, context.temp_allocator), " \t\n\r\v") text := strings.trim(
process_white_space(node.text, context.temp_allocator),
" \t\n\r\v",
)
// TODO(Paul): do something about newlines; have them be limited to 2 max if they appear consecutively perhaps // TODO(Paul): do something about newlines; have them be limited to 2 max if they appear consecutively perhaps
if text != "" { if text != "" {
fmt.sbprintln(sb, text) fmt.sbprintln(sb, text)
} }
case .Tag: case .Tag:
ltx_to_text_with_smart_indent(sb, node.children, should_inc_indent_level ? indent_level + 1 : indent_level) to_text_with_smart_indent(
sb,
node.children,
should_inc_indent_level ? indent_level + 1 : indent_level,
)
} }
} }
} }
// Ltx to plain text; strip ltx tags // Ltx to plain text; strip ltx tags
ltx_to_plain_text :: proc(sb: ^strings.Builder, nodes: [dynamic]Node) { to_plain_text :: proc(sb: ^strings.Builder, nodes: [dynamic]Node) {
for node in nodes { for node in nodes {
switch node.kind { switch node.kind {
case .Text: case .Text:
fmt.sbprint(sb, node.text) fmt.sbprint(sb, node.text)
case .Tag: case .Tag:
ltx_to_plain_text(sb, node.children) to_plain_text(sb, node.children)
} }
} }
} }
// Ltx to XML // Ltx to XML
ltx_to_xml :: proc(sb: ^strings.Builder, nodes: [dynamic]Node, depth := 0) { // TODO: CDATA garbage
to_xml :: proc(sb: ^strings.Builder, nodes: [dynamic]Node, depth := 0) {
if len(nodes) <= 0 do return if len(nodes) <= 0 do return
if depth == 0 do fmt.sbprintln(sb, "<ltx>") if depth == 0 do fmt.sbprintln(sb, "<ltx>")
for node in nodes { for node in nodes {
@@ -363,18 +372,18 @@ ltx_to_xml :: proc(sb: ^strings.Builder, nodes: [dynamic]Node, depth := 0) {
for k, v in node.attributes do fmt.sbprintf(sb, " %s=\"%s\"", k, v.value) for k, v in node.attributes do fmt.sbprintf(sb, " %s=\"%s\"", k, v.value)
if len(node.children) > 0 { if len(node.children) > 0 {
fmt.sbprint(sb, ">") fmt.sbprint(sb, ">")
ltx_to_xml(sb, node.children, depth + 1) to_xml(sb, node.children, depth + 1)
fmt.sbprintf(sb, "</%s>", node.name) fmt.sbprintf(sb, "</%s>", node.name)
} else { } else {
fmt.sbprint(sb, " />") fmt.sbprint(sb, " />")
} }
} }
} }
if depth == 0 do fmt.sbprintln(sb, "</ltx>") if depth == 0 do fmt.sbprint(sb, "\n</ltx>")
} }
// Ltx_Error to english string // Ltx_Error to english string
ltx_error_to_string :: proc(error: Ltx_Error) -> string { error_to_string :: proc(error: Ltx_Error) -> string {
switch error { switch error {
case .None: case .None:
return "" return ""
@@ -401,29 +410,41 @@ ltx_error_to_string :: proc(error: Ltx_Error) -> string {
} }
// Pretty format LTX error // Pretty format LTX error
ltx_get_error :: proc(ltx: ^Ltx, error: Ltx_Error, allocator := context.allocator) -> string { get_error :: proc(ltx: ^Ltx, error: Ltx_Error, allocator := context.allocator) -> string {
if error == .None do return "" if error == .None do return ""
file_path := len(ltx.source_path) > 0 ? ltx.source_path : "[source]" file_path := len(ltx.source_path) > 0 ? ltx.source_path : ""
line := ltx.pos.line + 1 line := ltx.pos.line + 1
col := ltx.pos.col + 1 col := ltx.pos.col + 1
error_msg := ltx_error_to_string(error) error_msg := error_to_string(error)
return fmt.aprintf("%s(%d,%d): error: %s", file_path, line, col, error_msg, allocator = allocator) return fmt.aprintf(
"%s(%d,%d): error: %s",
file_path,
line,
col,
error_msg,
allocator = allocator,
)
} }
// Escapes white space characters with in their c-string slash form // Escapes white space characters with in their c-string slash form
escape_white_space :: proc(s: string, allocator := context.allocator) -> string { escape_white_space :: proc(s: string, allocator := context.allocator) -> string {
sb := strings.builder_make(allocator) sb := strings.builder_make(allocator)
for c in s { for c in s {
switch c { switch c {
case '\t': strings.write_string(&sb, "\\t") case '\t':
case '\n': strings.write_string(&sb, "\\n") strings.write_string(&sb, "\\t")
case '\r': strings.write_string(&sb, "\\r") case '\n':
case '\\': strings.write_string(&sb, "\\\\") strings.write_string(&sb, "\\n")
case: strings.write_rune(&sb, c) case '\r':
} strings.write_string(&sb, "\\r")
} case '\\':
return strings.to_string(sb) strings.write_string(&sb, "\\\\")
case:
strings.write_rune(&sb, c)
}
}
return strings.to_string(sb)
} }
// Processes and normalizes whitespace in a multi-line string. // Processes and normalizes whitespace in a multi-line string.
+81
View File
@@ -0,0 +1,81 @@
package ltx
Ltx :: struct {
source: string,
source_path: string,
nodes: [dynamic]Node,
idx: int,
pos: struct {
line: u32,
col: u32,
},
}
Tokens :: enum {
Backslash,
LeftBrace,
RightBrace,
LeftBracket,
RightBracket,
Assign,
Quote,
}
TokenArray := [Tokens]rune {
.Assign = '=',
.Backslash = '\\',
.LeftBrace = '{',
.RightBrace = '}',
.LeftBracket = '[',
.RightBracket = ']',
.Quote = '"',
}
Node_Kind :: enum {
Text,
Tag,
}
Attr_Type :: enum {
Flag,
Attribute,
}
Field :: struct {
value: string,
type: Attr_Type,
}
Node :: struct {
name: string,
kind: Node_Kind,
text: string,
attributes: map[string]Field,
children: [dynamic]Node,
}
Ltx_Error :: enum u32 {
None = 0,
EOF,
KeyExpected,
ClosingBracketExpected,
ClosingBraceExpected,
UnexpectedRightBrace,
ValueExpected,
KeyAlreadyExists,
InvalidKey,
CannotReadFile,
// InvalidKeyStart,
}
Cli_Output_Type :: enum {
None,
PlainText,
XML,
SmartIndentedText,
}
Cli_Args :: struct {
type: Cli_Output_Type,
file_path: string,
}