diff --git a/test/snmpkit/snmp_lib/mib/snmp_tokenizer_test.exs b/test/snmpkit/snmp_lib/mib/snmp_tokenizer_test.exs new file mode 100644 index 00000000..0364cc0d --- /dev/null +++ b/test/snmpkit/snmp_lib/mib/snmp_tokenizer_test.exs @@ -0,0 +1,522 @@ +defmodule SnmpKit.SnmpLib.MIB.SnmpTokenizerTest do + use ExUnit.Case, async: true + + alias SnmpKit.SnmpLib.MIB.SnmpTokenizer + + describe "tokenize/2" do + test "tokenizes empty input" do + assert {:ok, tokens} = SnmpTokenizer.tokenize(~c"", &SnmpTokenizer.null_get_line/0) + assert [{:"$end", 1}] = tokens + end + + test "tokenizes simple MIB header" do + input = ~c"TEST-MIB DEFINITIONS ::= BEGIN END" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + # TEST-MIB is tokenized as :variable (uppercase identifier) + assert :variable in token_types + assert :DEFINITIONS in token_types + assert :BEGIN in token_types + assert :END in token_types + end + + test "tokenizes reserved words" do + reserved_words = [ + "DEFINITIONS", + "BEGIN", + "END", + "IMPORTS", + "FROM", + "OBJECT-TYPE", + "SYNTAX", + "MAX-ACCESS", + "STATUS", + "DESCRIPTION" + ] + + for word <- reserved_words do + input = String.to_charlist(word) + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # First token should be the reserved word + assert length(tokens) >= 1 + end + end + + test "tokenizes identifiers" do + input = ~c"testObject myIdentifier test123" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + atoms = tokens |> Enum.filter(&match?({:atom, _, _}, &1)) + assert length(atoms) == 3 + end + + test "tokenizes numbers" do + input = ~c"0 123 456789" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + numbers = tokens |> Enum.filter(&match?({:integer, _, _}, &1)) + assert length(numbers) == 3 + end + + test "tokenizes negative numbers" do + input = ~c"-1 -123" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Might be tokenized as minus sign + integer, depends on implementation + assert length(tokens) >= 2 + end + + test "tokenizes strings with quotes" do + input = ~c"\"test string\" \"another\"" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Strings might be tokenized as :string or just handled as tokens + # Just verify tokenization succeeds + assert length(tokens) >= 1 + end + + test "tokenizes special characters" do + input = ~c"::= { } ( ) , ; ." + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Should have various special character tokens + assert length(tokens) > 5 + end + + test "tokenizes OID notation" do + input = ~c"{ test 1 2 3 }" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Should have braces, identifier, and numbers + assert length(tokens) >= 6 + end + + test "tokenizes complete OBJECT-TYPE definition" do + input = ~c""" + testObject OBJECT-TYPE + SYNTAX Integer32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Test object" + ::= { test 1 } + """ + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :"OBJECT-TYPE" in token_types + assert :SYNTAX in token_types + assert :"MAX-ACCESS" in token_types + assert :STATUS in token_types + assert :DESCRIPTION in token_types + end + + test "tokenizes access modifiers" do + access_values = ["read-only", "read-write", "not-accessible", "read-create"] + + for access <- access_values do + input = String.to_charlist(access) + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + assert length(tokens) >= 1 + end + end + + test "tokenizes SMI types" do + types = ["INTEGER", "OCTET STRING", "Counter32", "Gauge32", "TimeTicks", "IpAddress"] + + for type <- types do + input = String.to_charlist(type) + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + assert length(tokens) >= 1 + end + end + + test "tokenizes hyphenated keywords" do + keywords = [ + "OBJECT-TYPE", + "MAX-ACCESS", + "MODULE-IDENTITY", + "OBJECT-IDENTITY", + "TEXTUAL-CONVENTION" + ] + + for keyword <- keywords do + input = String.to_charlist(keyword) + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Should tokenize as single keyword token + non_end_tokens = Enum.reject(tokens, &match?({:"$end", _}, &1)) + assert length(non_end_tokens) == 1 + end + end + + test "handles whitespace correctly" do + input = ~c" test 123 \n OBJECT-TYPE " + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Whitespace should be ignored, only meaningful tokens returned + non_end_tokens = Enum.reject(tokens, &match?({:"$end", _}, &1)) + assert length(non_end_tokens) == 3 + end + + test "handles comments" do + input = ~c"test -- this is a comment\nOBJECT-TYPE" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Comments should be ignored + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :atom in token_types + assert :"OBJECT-TYPE" in token_types + end + + test "handles multiline input" do + input = ~c""" + TEST-MIB + DEFINITIONS + ::= + BEGIN + END + """ + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + # TEST-MIB is tokenized as :variable (uppercase identifier) + assert :variable in token_types + assert :DEFINITIONS in token_types + assert :BEGIN in token_types + assert :END in token_types + end + + test "tokenizes with line numbers" do + input = ~c"test\nDEFINITIONS\nBEGIN" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Tokens should have different line numbers + lines = Enum.map(tokens, fn + {_type, line} -> line + {_type, _value, line} -> line + end) + + assert Enum.uniq(lines) |> length() > 1 + end + + test "preserves token order" do + input = ~c"first second third" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + atoms = tokens |> Enum.filter(&match?({:atom, _, _}, &1)) + + # Should be in order + assert length(atoms) == 3 + end + + test "handles complex MIB structure" do + input = ~c""" + TEST-MIB DEFINITIONS ::= BEGIN + IMPORTS + MODULE-IDENTITY FROM SNMPv2-SMI; + + testMIB MODULE-IDENTITY + LAST-UPDATED "202501010000Z" + ORGANIZATION "Test Org" + ::= { test 1 } + END + """ + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :DEFINITIONS in token_types + assert :BEGIN in token_types + assert :IMPORTS in token_types + assert :"MODULE-IDENTITY" in token_types + assert :FROM in token_types + assert :END in token_types + end + + test "tokenizes SIZE constraint" do + input = ~c"SIZE (0..255)" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :SIZE in token_types + end + + test "tokenizes SEQUENCE OF" do + input = ~c"SEQUENCE OF TestEntry" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :SEQUENCE in token_types + assert :OF in token_types + end + end + + describe "format_error/1" do + test "formats illegal character error" do + error = {:illegal, ?@} + formatted = SnmpTokenizer.format_error(error) + + assert is_list(formatted) + formatted_str = List.to_string(formatted) + assert formatted_str =~ "illegal character" + end + + test "formats unterminated string error" do + error = {:unterminated_string, 42} + formatted = SnmpTokenizer.format_error(error) + + assert is_list(formatted) + formatted_str = List.to_string(formatted) + assert formatted_str =~ "unterminated string" + assert formatted_str =~ "42" + end + + test "formats unterminated quote error" do + error = {:unterminated_quote, 10} + formatted = SnmpTokenizer.format_error(error) + + assert is_list(formatted) + formatted_str = List.to_string(formatted) + assert formatted_str =~ "unterminated quote" + assert formatted_str =~ "10" + end + + test "formats unknown error" do + error = {:unknown_error, "details"} + formatted = SnmpTokenizer.format_error(error) + + assert is_list(formatted) + end + + test "formats atom error" do + error = :some_error + formatted = SnmpTokenizer.format_error(error) + + assert is_list(formatted) + end + end + + describe "null_get_line/0" do + test "returns eof" do + assert SnmpTokenizer.null_get_line() == :eof + end + end + + describe "test/0" do + test "runs test function without crashing" do + # The test/0 function tokenizes a test string and prints output + # We just verify it doesn't crash + assert SnmpTokenizer.test() in [:ok, :error] + end + end + + describe "edge cases" do + test "handles very long identifiers" do + # Use a more reasonable length to avoid atom table limits (255 bytes max for atoms) + long_id = String.duplicate("a", 200) |> String.to_charlist() + + assert {:ok, tokens} = SnmpTokenizer.tokenize(long_id, &SnmpTokenizer.null_get_line/0) + assert length(tokens) >= 1 + end + + test "handles many tokens" do + # Create input with many identifiers + identifiers = Enum.map(1..100, fn i -> "id#{i}" end) |> Enum.join(" ") + input = String.to_charlist(identifiers) + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + atoms = tokens |> Enum.filter(&match?({:atom, _, _}, &1)) + assert length(atoms) == 100 + end + + test "handles deeply nested braces" do + input = ~c"{ { { test } } }" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + assert length(tokens) > 6 + end + + test "handles mixed case keywords" do + # Reserved words should be case-sensitive + input = ~c"begin BEGIN" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + # Only "BEGIN" should be recognized as keyword + begin_count = Enum.count(token_types, &(&1 == :BEGIN)) + assert begin_count == 1 + + # "begin" should be an atom (lowercase identifier) + atom_count = Enum.count(tokens, &match?({:atom, _, _}, &1)) + assert atom_count >= 1 + end + + test "handles unicode characters in strings" do + # Basic ASCII string should work + input = ~c"\"test\"" + + assert {:ok, _tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + end + + test "handles empty strings" do + input = ~c"\"\"" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + assert length(tokens) >= 1 + end + + test "handles consecutive operators" do + input = ~c"::=::=" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + # Should tokenize as two ::= operators or similar + assert length(tokens) >= 2 + end + + test "handles tabs and mixed whitespace" do + input = ~c"test\t\tOBJECT-TYPE\n\n BEGIN" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + assert :atom in token_types + assert :"OBJECT-TYPE" in token_types + assert :BEGIN in token_types + end + + test "handles numbers with leading zeros" do + input = ~c"001 0123" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + integers = tokens |> Enum.filter(&match?({:integer, _, _}, &1)) + assert length(integers) == 2 + end + + test "handles very large numbers" do + input = ~c"999999999999" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + integers = tokens |> Enum.filter(&match?({:integer, _, _}, &1)) + assert length(integers) == 1 + end + end + + describe "reserved word coverage" do + test "tokenizes all common reserved words" do + # Test a sampling of reserved words to ensure coverage + words = [ + "DEFINITIONS", + "BEGIN", + "END", + "IMPORTS", + "FROM", + "EXPORTS", + "OBJECT", + "IDENTIFIER", + "OBJECT-TYPE", + "SYNTAX", + "ACCESS", + "MAX-ACCESS", + "STATUS", + "DESCRIPTION", + "REFERENCE", + "INDEX", + "SEQUENCE", + "OF", + "SIZE", + "INTEGER", + "OCTET", + "STRING", + "Counter32", + "Gauge32", + "MODULE-IDENTITY", + "TEXTUAL-CONVENTION", + "OBJECT-GROUP", + "NOTIFICATION-TYPE" + ] + + for word <- words do + input = String.to_charlist(word) + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + # Should successfully tokenize without error + assert length(tokens) >= 1 + end + end + + test "distinguishes reserved words from similar identifiers" do + # "OBJECT" is reserved, "object" is lowercase atom + input = ~c"OBJECT object" + + assert {:ok, tokens} = SnmpTokenizer.tokenize(input, &SnmpTokenizer.null_get_line/0) + + token_types = Enum.map(tokens, fn + {type, _line} -> type + {type, _value, _line} -> type + end) + + # OBJECT should be keyword + assert :OBJECT in token_types + + # "object" should be an atom (lowercase identifier) + atoms = tokens |> Enum.filter(&match?({:atom, _, _}, &1)) + assert length(atoms) >= 1 + end + end +end