61c0981294
* add lsp.zig * change references from types.zig to lsp.zig * remove types.zig and requests.zig * add tres as a submodule * transition codebase from types.zig to lsp.zig * update lsp.zig * completely overhaul message handler * fix memory errors * partially transition tests to lsp.zig * update lsp.zig * more test fixes * disable failing tests * fix message handling bugs * fix remaining tests * access correct union in diff.applyTextEdits * more message handler fixes * run zig fmt * update tres submodule * fix memory access to freed memory * simplify initialize_msg for testing * check if publishDiagnostics is supported
598 lines
21 KiB
Zig
598 lines
21 KiB
Zig
const std = @import("std");
|
|
const Ast = std.zig.Ast;
|
|
|
|
const DocumentStore = @import("DocumentStore.zig");
|
|
const analysis = @import("analysis.zig");
|
|
const ast = @import("ast.zig");
|
|
|
|
const types = @import("lsp.zig");
|
|
const offsets = @import("offsets.zig");
|
|
|
|
pub const Builder = struct {
|
|
arena: *std.heap.ArenaAllocator,
|
|
document_store: *DocumentStore,
|
|
handle: *const DocumentStore.Handle,
|
|
offset_encoding: offsets.Encoding,
|
|
|
|
pub fn generateCodeAction(builder: *Builder, diagnostic: types.Diagnostic, actions: *std.ArrayListUnmanaged(types.CodeAction)) error{OutOfMemory}!void {
|
|
const kind = DiagnosticKind.parse(diagnostic.message) orelse return;
|
|
|
|
const loc = offsets.rangeToLoc(builder.handle.text, diagnostic.range, builder.offset_encoding);
|
|
|
|
switch (kind) {
|
|
.unused => |id| switch (id) {
|
|
.@"function parameter" => try handleUnusedFunctionParameter(builder, actions, loc),
|
|
.@"local constant" => try handleUnusedVariableOrConstant(builder, actions, loc),
|
|
.@"local variable" => try handleUnusedVariableOrConstant(builder, actions, loc),
|
|
.@"loop index capture" => try handleUnusedIndexCapture(builder, actions, loc),
|
|
.capture => try handleUnusedCapture(builder, actions, loc),
|
|
},
|
|
.non_camelcase_fn => try handleNonCamelcaseFunction(builder, actions, loc),
|
|
.pointless_discard => try handlePointlessDiscard(builder, actions, loc),
|
|
.omit_discard => |id| switch (id) {
|
|
.@"index capture" => try handleUnusedIndexCapture(builder, actions, loc),
|
|
.@"error capture" => try handleUnusedCapture(builder, actions, loc),
|
|
},
|
|
// the undeclared identifier may be a discard
|
|
.undeclared_identifier => try handlePointlessDiscard(builder, actions, loc),
|
|
.unreachable_code => {
|
|
// TODO
|
|
// autofix: comment out code
|
|
// fix: remove code
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn createTextEditLoc(self: *Builder, loc: offsets.Loc, new_text: []const u8) types.TextEdit {
|
|
const range = offsets.locToRange(self.handle.text, loc, self.offset_encoding);
|
|
return types.TextEdit{ .range = range, .newText = new_text };
|
|
}
|
|
|
|
pub fn createTextEditPos(self: *Builder, index: usize, new_text: []const u8) types.TextEdit {
|
|
const position = offsets.indexToPosition(self.handle.text, index, self.offset_encoding);
|
|
return types.TextEdit{ .range = .{ .start = position, .end = position }, .newText = new_text };
|
|
}
|
|
|
|
pub fn createWorkspaceEdit(self: *Builder, edits: []const types.TextEdit) error{OutOfMemory}!types.WorkspaceEdit {
|
|
const allocator = self.arena.allocator();
|
|
var workspace_edit = types.WorkspaceEdit{ .changes = .{} };
|
|
try workspace_edit.changes.?.putNoClobber(allocator, self.handle.uri, try allocator.dupe(types.TextEdit, edits));
|
|
|
|
return workspace_edit;
|
|
}
|
|
};
|
|
|
|
fn handleNonCamelcaseFunction(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const identifier_name = offsets.locToSlice(builder.handle.text, loc);
|
|
|
|
if (std.mem.allEqual(u8, identifier_name, '_')) return;
|
|
|
|
const new_text = try createCamelcaseText(builder.arena.allocator(), identifier_name);
|
|
|
|
const action1 = types.CodeAction{
|
|
.title = "make function name camelCase",
|
|
.kind = .quickfix,
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(loc, new_text)}),
|
|
};
|
|
|
|
try actions.append(builder.arena.allocator(), action1);
|
|
}
|
|
|
|
fn handleUnusedFunctionParameter(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const identifier_name = offsets.locToSlice(builder.handle.text, loc);
|
|
|
|
const tree = builder.handle.tree;
|
|
const node_tags = tree.nodes.items(.tag);
|
|
const node_datas = tree.nodes.items(.data);
|
|
const node_tokens = tree.nodes.items(.main_token);
|
|
|
|
const token_starts = tree.tokens.items(.start);
|
|
|
|
const decl = (try analysis.lookupSymbolGlobal(
|
|
builder.document_store,
|
|
builder.arena,
|
|
builder.handle,
|
|
identifier_name,
|
|
loc.start,
|
|
)) orelse return;
|
|
|
|
const payload = switch (decl.decl.*) {
|
|
.param_payload => |pay| pay,
|
|
else => return,
|
|
};
|
|
|
|
std.debug.assert(node_tags[payload.func] == .fn_decl);
|
|
|
|
const block = node_datas[payload.func].rhs;
|
|
|
|
const new_text = try createDiscardText(builder, identifier_name, token_starts[node_tokens[payload.func]], true);
|
|
|
|
const index = token_starts[node_tokens[block]] + 1;
|
|
|
|
const action1 = types.CodeAction{
|
|
.title = "discard function parameter",
|
|
.kind = .@"source.fixAll",
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditPos(index, new_text)}),
|
|
};
|
|
|
|
// TODO fix formatting
|
|
const action2 = types.CodeAction{
|
|
.title = "remove function parameter",
|
|
.kind = .quickfix,
|
|
.isPreferred = false,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(getParamRemovalRange(tree, payload.param), "")}),
|
|
};
|
|
|
|
try actions.appendSlice(builder.arena.allocator(), &.{ action1, action2 });
|
|
}
|
|
|
|
fn handleUnusedVariableOrConstant(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const identifier_name = offsets.locToSlice(builder.handle.text, loc);
|
|
|
|
const tree = builder.handle.tree;
|
|
const token_tags = tree.tokens.items(.tag);
|
|
const token_starts = tree.tokens.items(.start);
|
|
|
|
const decl = (try analysis.lookupSymbolGlobal(
|
|
builder.document_store,
|
|
builder.arena,
|
|
builder.handle,
|
|
identifier_name,
|
|
loc.start,
|
|
)) orelse return;
|
|
|
|
const node = switch (decl.decl.*) {
|
|
.ast_node => |node| node,
|
|
else => return,
|
|
};
|
|
|
|
const first_token = tree.firstToken(node);
|
|
const last_token = ast.lastToken(tree, node) + 1;
|
|
|
|
if (token_tags[last_token] != .semicolon) return;
|
|
|
|
const new_text = try createDiscardText(builder, identifier_name, token_starts[first_token], false);
|
|
|
|
const index = token_starts[last_token] + 1;
|
|
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "discard value",
|
|
.kind = .@"source.fixAll",
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditPos(index, new_text)}),
|
|
});
|
|
}
|
|
|
|
fn handleUnusedIndexCapture(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const capture_locs = getCaptureLoc(builder.handle.text, loc, true) orelse return;
|
|
|
|
// TODO support discarding without modifying the capture
|
|
// by adding a discard in the block scope
|
|
const is_value_discarded = std.mem.eql(u8, offsets.locToSlice(builder.handle.text, capture_locs.value), "_");
|
|
if (is_value_discarded) {
|
|
// |_, i| ->
|
|
// TODO fix formatting
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "remove capture",
|
|
.kind = .quickfix,
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.loc, "")}),
|
|
});
|
|
} else {
|
|
// |v, i| -> |v|
|
|
// |v, _| -> |v|
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "remove index capture",
|
|
.kind = .quickfix,
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(
|
|
.{ .start = capture_locs.value.end, .end = capture_locs.loc.end - 1 },
|
|
"",
|
|
)}),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handleUnusedCapture(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const capture_locs = getCaptureLoc(builder.handle.text, loc, false) orelse return;
|
|
|
|
// TODO support discarding without modifying the capture
|
|
// by adding a discard in the block scope
|
|
if (capture_locs.index != null) {
|
|
// |v, i| -> |_, i|
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "discard capture",
|
|
.kind = .quickfix,
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.value, "_")}),
|
|
});
|
|
} else {
|
|
// |v| ->
|
|
// TODO fix formatting
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "remove capture",
|
|
.kind = .quickfix,
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.loc, "")}),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handlePointlessDiscard(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
|
|
const edit_loc = getDiscardLoc(builder.handle.text, loc) orelse return;
|
|
|
|
try actions.append(builder.arena.allocator(), .{
|
|
.title = "remove pointless discard",
|
|
.kind = .@"source.fixAll",
|
|
.isPreferred = true,
|
|
.edit = try builder.createWorkspaceEdit(&.{
|
|
builder.createTextEditLoc(edit_loc, ""),
|
|
}),
|
|
});
|
|
}
|
|
|
|
fn detectIndentation(source: []const u8) []const u8 {
|
|
// Essentially I'm looking for the first indentation in the file.
|
|
var i: usize = 0;
|
|
var len = source.len - 1; // I need 1 look-ahead
|
|
while (i < len) : (i += 1) {
|
|
if (source[i] != '\n') continue;
|
|
i += 1;
|
|
if (source[i] == '\t') return "\t";
|
|
var space_count: usize = 0;
|
|
while (i < source.len and source[i] == ' ') : (i += 1) {
|
|
space_count += 1;
|
|
}
|
|
if (source[i] == '\n') { // Some editors mess up indentation of empty lines
|
|
i -= 1;
|
|
continue;
|
|
}
|
|
if (space_count == 0) continue;
|
|
if (source[i] == '/') continue; // Comments sometimes have additional alignment.
|
|
if (source[i] == '\\') continue; // multi-line strings might as well.
|
|
return source[i - space_count .. i];
|
|
}
|
|
return " " ** 4; // recommended style
|
|
}
|
|
|
|
// attempts to converts a slice of text into camelcase 'FUNCTION_NAME' -> 'functionName'
|
|
fn createCamelcaseText(allocator: std.mem.Allocator, identifier: []const u8) ![]const u8 {
|
|
// skip initial & ending underscores
|
|
const trimmed_identifier = std.mem.trim(u8, identifier, "_");
|
|
|
|
const num_separators = std.mem.count(u8, trimmed_identifier, "_");
|
|
|
|
const new_text_len = trimmed_identifier.len - num_separators;
|
|
var new_text = try std.ArrayListUnmanaged(u8).initCapacity(allocator, new_text_len);
|
|
errdefer new_text.deinit(allocator);
|
|
|
|
var idx: usize = 0;
|
|
while (idx < trimmed_identifier.len) {
|
|
const ch = trimmed_identifier[idx];
|
|
if (ch == '_') {
|
|
// the trimmed identifier is guaranteed to not have underscores at the end,
|
|
// so it can be assumed that ptr dereferences are safe until an alnum char is found
|
|
while (trimmed_identifier[idx] == '_') : (idx += 1) {}
|
|
const ch2 = trimmed_identifier[idx];
|
|
new_text.appendAssumeCapacity(std.ascii.toUpper(ch2));
|
|
} else {
|
|
new_text.appendAssumeCapacity(std.ascii.toLower(ch));
|
|
}
|
|
|
|
idx += 1;
|
|
}
|
|
|
|
return new_text.toOwnedSlice(allocator);
|
|
}
|
|
|
|
// returns a discard string `\n{indent}_ = identifier_name;`
|
|
fn createDiscardText(builder: *Builder, identifier_name: []const u8, declaration_start: usize, add_block_indentation: bool) ![]const u8 {
|
|
const indent = find_indent: {
|
|
const line = offsets.lineSliceUntilIndex(builder.handle.text, declaration_start);
|
|
for (line) |char, i| {
|
|
if (!std.ascii.isWhitespace(char)) {
|
|
break :find_indent line[0..i];
|
|
}
|
|
}
|
|
break :find_indent line;
|
|
};
|
|
const additional_indent = if (add_block_indentation) detectIndentation(builder.handle.text) else "";
|
|
|
|
const allocator = builder.arena.allocator();
|
|
const new_text_len = 1 + indent.len + additional_indent.len + "_ = ;".len + identifier_name.len;
|
|
var new_text = try std.ArrayListUnmanaged(u8).initCapacity(allocator, new_text_len);
|
|
errdefer new_text.deinit(allocator);
|
|
|
|
new_text.appendAssumeCapacity('\n');
|
|
new_text.appendSliceAssumeCapacity(indent);
|
|
new_text.appendSliceAssumeCapacity(additional_indent);
|
|
new_text.appendSliceAssumeCapacity("_ = ");
|
|
new_text.appendSliceAssumeCapacity(identifier_name);
|
|
new_text.appendAssumeCapacity(';');
|
|
|
|
return new_text.toOwnedSlice(allocator);
|
|
}
|
|
|
|
fn getParamRemovalRange(tree: Ast, param: Ast.full.FnProto.Param) offsets.Loc {
|
|
var param_start = offsets.tokenToIndex(tree, ast.paramFirstToken(tree, param));
|
|
var param_end = offsets.tokenToLoc(tree, ast.paramLastToken(tree, param)).end;
|
|
|
|
var trim_end = false;
|
|
while (param_start != 0) : (param_start -= 1) {
|
|
switch (tree.source[param_start - 1]) {
|
|
' ', '\n' => continue,
|
|
',' => {
|
|
param_start -= 1;
|
|
break;
|
|
},
|
|
'(' => {
|
|
trim_end = true;
|
|
break;
|
|
},
|
|
else => break,
|
|
}
|
|
}
|
|
|
|
var found_comma = false;
|
|
while (trim_end and param_end < tree.source.len) : (param_end += 1) {
|
|
switch (tree.source[param_end]) {
|
|
' ', '\n' => continue,
|
|
',' => if (!found_comma) {
|
|
found_comma = true;
|
|
continue;
|
|
} else {
|
|
param_end += 1;
|
|
break;
|
|
},
|
|
')' => break,
|
|
else => break,
|
|
}
|
|
}
|
|
|
|
return .{ .start = param_start, .end = param_end };
|
|
}
|
|
|
|
const DiagnosticKind = union(enum) {
|
|
unused: IdCat,
|
|
pointless_discard: IdCat,
|
|
omit_discard: DiscardCat,
|
|
non_camelcase_fn,
|
|
undeclared_identifier,
|
|
unreachable_code,
|
|
|
|
const IdCat = enum {
|
|
@"function parameter",
|
|
@"local constant",
|
|
@"local variable",
|
|
@"loop index capture",
|
|
capture,
|
|
};
|
|
|
|
const DiscardCat = enum {
|
|
// "discard of index capture; omit it instead"
|
|
@"index capture",
|
|
// "discard of error capture; omit it instead"
|
|
@"error capture",
|
|
};
|
|
|
|
pub fn parse(diagnostic_message: []const u8) ?DiagnosticKind {
|
|
const msg = diagnostic_message;
|
|
|
|
if (std.mem.startsWith(u8, msg, "unused ")) {
|
|
return DiagnosticKind{
|
|
.unused = parseEnum(IdCat, msg["unused ".len..]) orelse return null,
|
|
};
|
|
} else if (std.mem.startsWith(u8, msg, "pointless discard of ")) {
|
|
return DiagnosticKind{
|
|
.pointless_discard = parseEnum(IdCat, msg["pointless discard of ".len..]) orelse return null,
|
|
};
|
|
} else if (std.mem.startsWith(u8, msg, "discard of ")) {
|
|
return DiagnosticKind{
|
|
.omit_discard = parseEnum(DiscardCat, msg["discard of ".len..]) orelse return null,
|
|
};
|
|
} else if (std.mem.startsWith(u8, msg, "Functions should be camelCase")) {
|
|
return .non_camelcase_fn;
|
|
} else if (std.mem.startsWith(u8, msg, "use of undeclared identifier")) {
|
|
return .undeclared_identifier;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn parseEnum(comptime T: type, message: []const u8) ?T {
|
|
inline for (std.meta.fields(T)) |field| {
|
|
if (std.mem.startsWith(u8, message, field.name)) {
|
|
// is there a better way to achieve this?
|
|
return @intToEnum(T, field.value);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/// takes the location of an identifier which is part of a discard `_ = location_here;`
|
|
/// and returns the location from '_' until ';' or null on failure
|
|
fn getDiscardLoc(text: []const u8, loc: offsets.Loc) ?offsets.Loc {
|
|
// check of the loc points to a valid identifier
|
|
for (offsets.locToSlice(text, loc)) |c| {
|
|
if (!isSymbolChar(c)) return null;
|
|
}
|
|
|
|
// check if the identifier is followed by a colon
|
|
const colon_position = found: {
|
|
var i = loc.end;
|
|
while (i < text.len) : (i += 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
';' => break :found i + 1,
|
|
else => return null,
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// check if the identifier is preceed by a equal sign and then an underscore
|
|
var i: usize = loc.start - 1;
|
|
var found_equal_sign = false;
|
|
const underscore_position = found: {
|
|
while (true) : (i -= 1) {
|
|
if (i == 0) return null;
|
|
switch (text[i]) {
|
|
' ' => {},
|
|
'=' => {
|
|
if (found_equal_sign) return null;
|
|
found_equal_sign = true;
|
|
},
|
|
'_' => if (found_equal_sign) break :found i else return null,
|
|
else => return null,
|
|
}
|
|
}
|
|
};
|
|
|
|
// move backwards until we find a newline
|
|
i = underscore_position - 1;
|
|
const start_position = found: {
|
|
while (true) : (i -= 1) {
|
|
if (i == 0) break :found underscore_position;
|
|
switch (text[i]) {
|
|
' ' => {},
|
|
'\n' => break :found i,
|
|
else => break :found underscore_position,
|
|
}
|
|
}
|
|
};
|
|
|
|
return offsets.Loc{
|
|
.start = start_position,
|
|
.end = colon_position,
|
|
};
|
|
}
|
|
|
|
const CaptureLocs = struct {
|
|
loc: offsets.Loc,
|
|
value: offsets.Loc,
|
|
index: ?offsets.Loc,
|
|
};
|
|
|
|
/// takes the location of an identifier which is part of a payload `|value, index|`
|
|
/// and returns the location from '|' until '|' or null on failure
|
|
/// use `is_index_payload` to indicate whether `loc` points to `value` or `index`
|
|
fn getCaptureLoc(text: []const u8, loc: offsets.Loc, is_index_payload: bool) ?CaptureLocs {
|
|
const value_end = if (!is_index_payload) loc.end else found: {
|
|
// move back until we find a comma
|
|
const comma_position = found_comma: {
|
|
var i = loc.start - 1;
|
|
while (i != 0) : (i -= 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
',' => break :found_comma i,
|
|
else => return null,
|
|
}
|
|
} else return null;
|
|
};
|
|
|
|
// trim space
|
|
var i = comma_position - 1;
|
|
while (i != 0) : (i -= 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
else => {
|
|
if (!isSymbolChar(text[i])) return null;
|
|
break :found i + 1;
|
|
},
|
|
}
|
|
} else return null;
|
|
};
|
|
|
|
const value_start = if (!is_index_payload) loc.start else found: {
|
|
// move back until we find a non identifier character
|
|
var i = value_end - 1;
|
|
while (i != 0) : (i -= 1) {
|
|
if (isSymbolChar(text[i])) continue;
|
|
switch (text[i]) {
|
|
' ', '|', '*' => break :found i + 1,
|
|
else => return null,
|
|
}
|
|
} else return null;
|
|
};
|
|
|
|
var index: ?offsets.Loc = null;
|
|
|
|
if (is_index_payload) {
|
|
index = loc;
|
|
} else blk: {
|
|
// move forward until we find a comma
|
|
const comma_position = found_comma: {
|
|
var i = value_end;
|
|
while (i < text.len) : (i += 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
',' => break :found_comma i,
|
|
else => break :blk,
|
|
}
|
|
}
|
|
break :blk;
|
|
};
|
|
|
|
// trim space
|
|
const index_start = found_start: {
|
|
var i = comma_position + 1;
|
|
while (i < text.len) : (i += 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
else => {
|
|
if (!isSymbolChar(text[i])) break :blk;
|
|
break :found_start i;
|
|
},
|
|
}
|
|
}
|
|
break :blk;
|
|
};
|
|
|
|
// move forward until we find a non identifier character
|
|
var i = index_start + 1;
|
|
while (i < text.len) : (i += 1) {
|
|
if (isSymbolChar(text[i])) continue;
|
|
index = offsets.Loc{
|
|
.start = index_start,
|
|
.end = i,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
|
|
const start_pipe_position = found: {
|
|
var i = value_start - 1;
|
|
while (i != 0) : (i -= 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
'|' => break :found i,
|
|
else => return null,
|
|
}
|
|
} else return null;
|
|
};
|
|
|
|
const end_pipe_position = found: {
|
|
var i: usize = if (index) |index_loc| index_loc.end else value_end;
|
|
while (i < text.len) : (i += 1) {
|
|
switch (text[i]) {
|
|
' ' => continue,
|
|
'|' => break :found i + 1,
|
|
else => return null,
|
|
}
|
|
} else return null;
|
|
};
|
|
|
|
return CaptureLocs{
|
|
.loc = .{ .start = start_pipe_position, .end = end_pipe_position },
|
|
.value = .{ .start = value_start, .end = value_end },
|
|
.index = index,
|
|
};
|
|
}
|
|
|
|
fn isSymbolChar(char: u8) bool {
|
|
return std.ascii.isAlphanumeric(char) or char == '_';
|
|
}
|