towerops/lib/snmpkit/snmp_lib/mib/snmp_tokenizer.ex

682 lines
19 KiB
Elixir

defmodule SnmpKit.SnmpLib.MIB.SnmpTokenizer do
@moduledoc """
True 1:1 Elixir port of Erlang SNMP tokenizer (snmpc_tok.erl).
This is a direct translation of the official Erlang SNMP tokenizer
from OTP lib/snmp/src/compile/snmpc_tok.erl
Original copyright: Ericsson AB 1996-2025 (Apache License 2.0)
"""
use GenServer
# State record equivalent
defstruct line: 1, chars: [], get_line_fun: nil
# Reserved words from SNMP/SMI - complete list from Erlang tokenizer
# Using a Map for O(1) lookup and reduced cyclomatic complexity
@reserved_words %{
"DEFINITIONS" => :DEFINITIONS,
"BEGIN" => :BEGIN,
"END" => :END,
"IMPORTS" => :IMPORTS,
"FROM" => :FROM,
"EXPORTS" => :EXPORTS,
"OBJECT" => :OBJECT,
"IDENTIFIER" => :IDENTIFIER,
"OBJECT-TYPE" => :"OBJECT-TYPE",
"SYNTAX" => :SYNTAX,
"ACCESS" => :ACCESS,
"MAX-ACCESS" => :"MAX-ACCESS",
"STATUS" => :STATUS,
"DESCRIPTION" => :DESCRIPTION,
"REFERENCE" => :REFERENCE,
"INDEX" => :INDEX,
"AUGMENTS" => :AUGMENTS,
"DEFVAL" => :DEFVAL,
"UNITS" => :UNITS,
"SEQUENCE" => :SEQUENCE,
"OF" => :OF,
"CHOICE" => :CHOICE,
"SIZE" => :SIZE,
"INTEGER" => :INTEGER,
"OCTET" => :OCTET,
"STRING" => :STRING,
"NULL" => :NULL,
"IpAddress" => :IpAddress,
"Counter" => :Counter,
"Counter32" => :Counter32,
"Counter64" => :Counter64,
"Gauge" => :Gauge,
"Gauge32" => :Gauge32,
"TimeTicks" => :TimeTicks,
"Unsigned32" => :Unsigned32,
"Integer32" => :Integer32,
"Opaque" => :Opaque,
"BITS" => :BITS,
"MODULE-IDENTITY" => :"MODULE-IDENTITY",
"OBJECT-IDENTITY" => :"OBJECT-IDENTITY",
"TEXTUAL-CONVENTION" => :"TEXTUAL-CONVENTION",
"OBJECT-GROUP" => :"OBJECT-GROUP",
"NOTIFICATION-GROUP" => :"NOTIFICATION-GROUP",
"MODULE-COMPLIANCE" => :"MODULE-COMPLIANCE",
"AGENT-CAPABILITIES" => :"AGENT-CAPABILITIES",
"NOTIFICATION-TYPE" => :"NOTIFICATION-TYPE",
"TRAP-TYPE" => :"TRAP-TYPE",
"LAST-UPDATED" => :"LAST-UPDATED",
"ORGANIZATION" => :ORGANIZATION,
"CONTACT-INFO" => :"CONTACT-INFO",
"REVISION" => :REVISION,
"DISPLAY-HINT" => :"DISPLAY-HINT",
"IMPLIED" => :IMPLIED,
"OBJECTS" => :OBJECTS,
"NOTIFICATIONS" => :NOTIFICATIONS,
"MANDATORY-GROUPS" => :"MANDATORY-GROUPS",
"GROUP" => :GROUP,
"MODULE" => :MODULE,
"WRITE-SYNTAX" => :"WRITE-SYNTAX",
"MIN-ACCESS" => :"MIN-ACCESS",
"PRODUCT-RELEASE" => :"PRODUCT-RELEASE",
"SUPPORTS" => :SUPPORTS,
"INCLUDES" => :INCLUDES,
"VARIATION" => :VARIATION,
"CREATION-REQUIRES" => :"CREATION-REQUIRES",
"ENTERPRISE" => :ENTERPRISE,
"VARIABLES" => :VARIABLES,
"APPLICATION" => :APPLICATION,
"IMPLICIT" => :IMPLICIT,
"EXPLICIT" => :EXPLICIT,
"UNIVERSAL" => :UNIVERSAL,
"PRIVATE" => :PRIVATE,
"MACRO" => :MACRO,
"TYPE" => :TYPE,
"NOTATION" => :NOTATION,
"VALUE" => :VALUE,
# Access values
"read-only" => :"read-only",
"read-write" => :"read-write",
"write-only" => :"write-only",
"not-accessible" => :"not-accessible",
"accessible-for-notify" => :"accessible-for-notify",
"read-create" => :"read-create"
}
@type state() :: %__MODULE__{
line: pos_integer(),
chars: charlist(),
get_line_fun: function() | nil
}
@type token() :: {atom(), any(), pos_integer()}
# API Functions - exact equivalents from Erlang
@doc """
Start tokenizer gen_server.
Equivalent to snmpc_tok:start_link/2
"""
@spec start_link(charlist(), pid()) :: {:ok, pid()} | {:error, term()}
def start_link(chars, get_line_pid) when is_list(chars) and is_pid(get_line_pid) do
GenServer.start_link(__MODULE__, {chars, get_line_pid}, [])
end
@doc """
Get next token from tokenizer.
Equivalent to snmpc_tok:get_token/1
"""
@spec get_token(pid()) :: {:ok, token()} | {:error, term()}
def get_token(pid) do
GenServer.call(pid, :get_token)
end
@doc """
Get all remaining tokens.
Equivalent to snmpc_tok:get_all_tokens/1
"""
@spec get_all_tokens(pid()) :: {:ok, [token()]} | {:error, term()}
def get_all_tokens(pid) do
GenServer.call(pid, :get_all_tokens)
end
@doc """
Tokenize a string directly.
Equivalent to snmpc_tok:tokenize/2
"""
@spec tokenize(charlist(), function()) :: {:ok, [token()]} | {:error, term()}
def tokenize(chars, _get_line_fun) when is_list(chars) do
# For direct tokenization, we don't need the gen_server complexity
# Just tokenize the input directly
state = %__MODULE__{
line: 1,
chars: chars,
get_line_fun: &null_get_line/0
}
case tokenize_all_direct(state, []) do
{:ok, tokens} -> {:ok, Enum.reverse(tokens)}
{:error, reason} -> {:error, reason}
end
end
# Direct tokenization without gen_server
defp tokenize_all_direct(state, acc) do
case tokenise(state) do
{{:"$end", _line}, _new_state} ->
{:ok, [{:"$end", state.line} | acc]}
{{:eof, _line}, _new_state} ->
{:ok, [{:"$end", state.line} | acc]}
{token, new_state} ->
tokenize_all_direct(new_state, [token | acc])
{:error, reason, _new_state} ->
{:error, reason}
end
end
@doc """
Stop tokenizer.
Equivalent to snmpc_tok:stop/1
"""
@spec stop(pid()) :: :ok
def stop(pid) do
GenServer.call(pid, :stop)
end
@doc """
Format error message.
Equivalent to snmpc_tok:format_error/1
"""
@spec format_error(term()) :: charlist()
def format_error(error) do
case error do
{:illegal, char} ->
:io_lib.format(~c"illegal character '~c'", [char])
{:unterminated_string, line} ->
:io_lib.format(~c"unterminated string starting at line ~p", [line])
{:unterminated_quote, line} ->
:io_lib.format(~c"unterminated quote starting at line ~p", [line])
other ->
:io_lib.format(~c"~p", [other])
end
end
@doc """
Null get_line function.
Equivalent to snmpc_tok:null_get_line/0
"""
@spec null_get_line() :: :eof
def null_get_line, do: :eof
@doc """
Test function.
Equivalent to snmpc_tok:test/0
"""
def test do
test_string =
~c"TEST-MIB DEFINITIONS ::= BEGIN testObject OBJECT IDENTIFIER ::= { test 1 } END"
case tokenize(test_string, &null_get_line/0) do
{:ok, tokens} ->
:io.format(~c"Tokens: ~p~n", [tokens])
:ok
{:error, reason} ->
:io.format(~c"Error: ~p~n", [reason])
:error
end
end
# GenServer callbacks
@impl true
def init({chars, get_line_pid}) do
get_line_fun = fn ->
case GenServer.call(get_line_pid, :get_line) do
line when is_list(line) -> line
:eof -> :eof
other -> other
end
end
state = %__MODULE__{
line: 1,
chars: chars,
get_line_fun: get_line_fun
}
{:ok, state}
end
@impl true
def handle_call(:get_token, _from, state) do
case tokenise(state) do
{token, new_state} ->
{:reply, {:ok, token}, new_state}
{:error, reason, new_state} ->
{:reply, {:error, reason}, new_state}
end
end
@impl true
def handle_call(:get_all_tokens, _from, state) do
case get_all_tokens_loop(state, []) do
{:ok, tokens, new_state} ->
{:reply, {:ok, Enum.reverse(tokens)}, new_state}
{:error, reason, new_state} ->
{:reply, {:error, reason}, new_state}
end
end
@impl true
def handle_call(:stop, _from, state) do
{:stop, :normal, :ok, state}
end
@impl true
def terminate(_reason, _state) do
:ok
end
# Private tokenization functions - direct ports from Erlang
# Get all tokens loop
defp get_all_tokens_loop(state, acc) do
case tokenise(state) do
{{:eof, _line}, new_state} ->
{:ok, [{:eof, state.line} | acc], new_state}
{token, new_state} ->
get_all_tokens_loop(new_state, [token | acc])
{:error, reason, new_state} ->
{:error, reason, new_state}
end
end
# Main tokenization function - direct port from tokenise/1
defp tokenise(%__MODULE__{chars: []} = state) do
# For direct tokenization, we've reached the end
{{:"$end", state.line}, state}
end
defp tokenise(%__MODULE__{chars: [?\s | chars]} = state) do
# Skip whitespace
new_state = %{state | chars: chars}
tokenise(new_state)
end
defp tokenise(%__MODULE__{chars: [?\t | chars]} = state) do
# Skip tab
new_state = %{state | chars: chars}
tokenise(new_state)
end
defp tokenise(%__MODULE__{chars: [?\n | chars]} = state) do
# Handle newline
new_state = %{state | chars: chars, line: state.line + 1}
tokenise(new_state)
end
defp tokenise(%__MODULE__{chars: [?\r | chars]} = state) do
# Skip carriage return
new_state = %{state | chars: chars}
tokenise(new_state)
end
defp tokenise(%__MODULE__{chars: [?-, ?- | chars]} = state) do
# Handle comments --
new_state = %{state | chars: chars}
skip_comment(new_state)
end
defp tokenise(%__MODULE__{chars: [?" | chars]} = state) do
# Handle string literals
scan_string(chars, [], state.line, %{state | chars: chars})
end
defp tokenise(%__MODULE__{chars: [?' | chars]} = state) do
# Handle quoted atoms
scan_quote(chars, [], state.line, %{state | chars: chars})
end
defp tokenise(%__MODULE__{chars: [?{ | chars]} = state) do
# Left brace
new_state = %{state | chars: chars}
{{:"{", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?} | chars]} = state) do
# Right brace
new_state = %{state | chars: chars}
{{:"}", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?( | chars]} = state) do
# Left parenthesis
new_state = %{state | chars: chars}
{{:"(", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?) | chars]} = state) do
# Right parenthesis
new_state = %{state | chars: chars}
{{:")", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?[ | chars]} = state) do
# Left bracket
new_state = %{state | chars: chars}
{{:"[", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?] | chars]} = state) do
# Right bracket
new_state = %{state | chars: chars}
{{:"]", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?, | chars]} = state) do
# Comma
new_state = %{state | chars: chars}
{{:",", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?; | chars]} = state) do
# Semicolon
new_state = %{state | chars: chars}
{{:";", state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?| | chars]} = state) do
# Pipe
new_state = %{state | chars: chars}
{{:|, state.line}, new_state}
end
defp tokenise(%__MODULE__{chars: [?: | chars]} = state) do
# Handle :: and ::=
case chars do
[?: | more_chars] ->
case more_chars do
[?= | remaining] ->
# ::=
new_state = %{state | chars: remaining}
{{:"::=", state.line}, new_state}
_ ->
# ::
new_state = %{state | chars: more_chars}
{{:"::", state.line}, new_state}
end
_ ->
# :
new_state = %{state | chars: chars}
{{:":", state.line}, new_state}
end
end
defp tokenise(%__MODULE__{chars: [?. | chars]} = state) do
# Handle .. (range)
case chars do
[?. | more_chars] ->
# ..
new_state = %{state | chars: more_chars}
{{:.., state.line}, new_state}
_ ->
# .
new_state = %{state | chars: chars}
{{:., state.line}, new_state}
end
end
defp tokenise(%__MODULE__{chars: [?- | chars]} = state) do
# Handle negative numbers or minus
case chars do
[digit | _] when digit >= ?0 and digit <= ?9 ->
scan_integer(state.chars, [], state.line, state)
_ ->
# Single minus
new_state = %{state | chars: chars}
{{:-, state.line}, new_state}
end
end
defp tokenise(%__MODULE__{chars: [digit | _chars]} = state) when digit >= ?0 and digit <= ?9 do
# Handle positive integers
scan_integer(state.chars, [], state.line, state)
end
defp tokenise(%__MODULE__{chars: [char | _chars]} = state)
when (char >= ?a and char <= ?z) or (char >= ?A and char <= ?Z) or char == ?_ do
# Handle identifiers and atoms
scan_name(state.chars, [], state.line, state)
end
defp tokenise(%__MODULE__{chars: [char | chars]} = state) do
# Illegal character
new_state = %{state | chars: chars}
{:error, {:illegal, char}, new_state}
end
# Skip comment until end of line
defp skip_comment(%__MODULE__{chars: []} = state) do
tokenise(state)
end
defp skip_comment(%__MODULE__{chars: [?\n | chars]} = state) do
new_state = %{state | chars: chars, line: state.line + 1}
tokenise(new_state)
end
defp skip_comment(%__MODULE__{chars: [_char | chars]} = state) do
new_state = %{state | chars: chars}
skip_comment(new_state)
end
# Scan string literal
defp scan_string([], _acc, start_line, state) do
{:error, {:unterminated_string, start_line}, state}
end
defp scan_string([?" | chars], acc, _start_line, state) do
# End of string - convert to Elixir string
string_value = acc |> Enum.reverse() |> List.to_string()
new_state = %{state | chars: chars}
{{:string, state.line, string_value}, new_state}
end
defp scan_string([?\\ | chars], acc, start_line, state) do
# Handle escape sequences
case chars do
[escaped_char | rest] ->
new_acc = [escaped_char | acc]
scan_string(rest, new_acc, start_line, state)
[] ->
{:error, {:unterminated_string, start_line}, state}
end
end
defp scan_string([?\n | chars], acc, start_line, state) do
# Newline in string
new_state = %{state | chars: chars, line: state.line + 1}
scan_string(chars, [?\n | acc], start_line, new_state)
end
defp scan_string([char | chars], acc, start_line, state) do
# Regular character
scan_string(chars, [char | acc], start_line, state)
end
# Scan quoted atom
defp scan_quote([], _acc, start_line, state) do
{:error, {:unterminated_quote, start_line}, state}
end
defp scan_quote([?' | chars], acc, _start_line, state) do
# End of quote - check for hex string suffix
case chars do
[?H | remaining_chars] ->
# Hex string with uppercase H suffix: 'FF'H - treat as special atom
hex_chars = Enum.reverse(acc)
# Create a special atom that the grammar can recognize
hex_atom =
case hex_chars do
# Empty hex string
[] -> :""
_ -> List.to_atom(hex_chars)
end
new_state = %{state | chars: remaining_chars}
{{:atom, state.line, hex_atom}, new_state}
[?h | remaining_chars] ->
# Hex string with lowercase h suffix: 'FF'h - treat as special atom
hex_chars = Enum.reverse(acc)
hex_atom =
case hex_chars do
# Empty hex string
[] -> :""
_ -> List.to_atom(hex_chars)
end
new_state = %{state | chars: remaining_chars}
{{:atom, state.line, hex_atom}, new_state}
_ ->
# Regular quoted atom
atom_chars = Enum.reverse(acc)
atom_value = List.to_atom(atom_chars)
new_state = %{state | chars: chars}
{{:atom, state.line, atom_value}, new_state}
end
end
defp scan_quote([?\\ | chars], acc, start_line, state) do
# Handle escape sequences in quotes
case chars do
[escaped_char | rest] ->
new_acc = [escaped_char | acc]
scan_quote(rest, new_acc, start_line, state)
[] ->
{:error, {:unterminated_quote, start_line}, state}
end
end
defp scan_quote([char | chars], acc, start_line, state) do
# Regular character in quote
scan_quote(chars, [char | acc], start_line, state)
end
# Scan integer
defp scan_integer([?- | chars], [], line, state) do
# Negative number
scan_integer_digits(chars, [?-], line, state)
end
defp scan_integer(chars, [], line, state) do
# Positive number
scan_integer_digits(chars, [], line, state)
end
defp scan_integer_digits([digit | chars], acc, line, state)
when (digit >= ?0 and digit <= ?9) or (digit >= ?a and digit <= ?f) or (digit >= ?A and digit <= ?F) do
# Include hex digits in integer scanning for large hex numbers like '7FFFFFFF'
scan_integer_digits(chars, [digit | acc], line, state)
end
defp scan_integer_digits(chars, acc, line, state) do
# End of integer - determine if it's hex or decimal
integer_chars = Enum.reverse(acc)
# Check if it contains hex digits
has_hex_digits =
Enum.any?(integer_chars, fn char ->
(char >= ?a and char <= ?f) or (char >= ?A and char <= ?F)
end)
if has_hex_digits do
# It's a hex number - convert from hex to decimal
hex_string = List.to_string(integer_chars)
try do
integer_value = String.to_integer(hex_string, 16)
new_state = %{state | chars: chars}
{{:integer, line, integer_value}, new_state}
rescue
_ ->
# Fall back to treating as atom if hex conversion fails
atom_value = String.to_atom(hex_string)
new_state = %{state | chars: chars}
{{:atom, line, atom_value}, new_state}
end
else
# Regular decimal integer
integer_value = List.to_integer(integer_chars)
new_state = %{state | chars: chars}
{{:integer, line, integer_value}, new_state}
end
end
# Scan identifier/atom name
defp scan_name([char | chars], acc, line, state)
when (char >= ?a and char <= ?z) or (char >= ?A and char <= ?Z) or (char >= ?0 and char <= ?9) or char == ?_ or
char == ?- do
scan_name(chars, [char | acc], line, state)
end
defp scan_name(chars, acc, line, state) do
# End of name
name_chars = Enum.reverse(acc)
name_string = List.to_string(name_chars)
# Determine if it's a reserved word, variable, or atom
token =
case classify_name(name_string, name_chars) do
{:reserved, atom} ->
{atom, line}
{:variable, _} ->
{:variable, line, name_string}
{:atom, _} ->
{:atom, line, String.to_atom(name_string)}
end
new_state = %{state | chars: chars}
{token, new_state}
end
# Classify name as reserved word, variable, or atom
defp classify_name(name_string, name_chars) do
# Check if it's a reserved word first
case reserved_word(name_string) do
nil ->
# Not a reserved word, check if variable or atom
case name_chars do
[first_char | _] when first_char >= ?A and first_char <= ?Z ->
{:variable, name_string}
_ ->
{:atom, name_string}
end
reserved_atom ->
{:reserved, reserved_atom}
end
end
defp reserved_word(word), do: Map.get(@reserved_words, word)
end