diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2ec9dde --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,59 @@ +name: Test + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +jobs: + test-v: + name: V Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install V + uses: vlang/setup-v@v1.4 + + - name: Get V version + run: v --version + + - name: Build gaslsp + run: | + cd src + v -o gaslsp . + + - name: Run integration tests + run: | + mkdir -p ~/.local/bin + cp src/gaslsp ~/.local/bin/gaslsp + cp -r src/tables ~/.local/bin/ + chmod +x tests/integration.sh tests/test_diags.sh + tests/integration.sh + + test-diags: + name: Diagnostic Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install V + uses: vlang/setup-v@v1.4 + + - name: Build gaslsp and test_diags + run: | + cd src + v -o gaslsp . + cd ../tests + v -o test_diags test_diags.v + + - name: Install and test diagnostics + run: | + mkdir -p ~/.local/bin + cp src/gaslsp ~/.local/bin/gaslsp + cp tests/test_diags ~/.local/bin/test_diags + cp -r src/tables ~/.local/bin/ + chmod +x tests/test_diags.sh + WORKSPACE="$(pwd)" tests/test_diags.sh diff --git a/.gitignore b/.gitignore index aeee732..e96a75d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules *.vsix + +gaslsp +test_diags \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore index 435315e..5e607bf 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,9 @@ -.vscode/** +.vscode/ +.github/ .gitignore +.gitattributes +.editorconfig src/ docs/ +tests/ diff --git a/docs/diagnostics.rst b/docs/diagnostics.rst index 25bfdef..6de3495 100644 --- a/docs/diagnostics.rst +++ b/docs/diagnostics.rst @@ -155,6 +155,21 @@ State Diagnostics (D034) - Warning - Register may be read before being written (uninitialized) +Statement Diagnostics (D020) +------------------------------ + +.. list-table:: + :header-rows: 1 + :widths: 15 15 70 + + * - Code + - Severity + - Description + + * - D020 + - Hint + - TODO/FIXME/HACK/XXX/BUG comment found + Configuration -------------- @@ -173,3 +188,4 @@ Diagnostics can be suppressed or promoted to errors in ``gaslsp.toml``: abi = true # D016-D017 symbol = true # D006-D008, D019 state = true # D034: uninitialized register tracking + statements = true # D020: TODO/FIXME comments diff --git a/docs/gaslsp.toml b/docs/gaslsp.toml index 1d46075..2ceeb48 100644 --- a/docs/gaslsp.toml +++ b/docs/gaslsp.toml @@ -38,6 +38,8 @@ operand = true # D004-D005, D009-D010, D018: operand issues encoding = true # D011-D015: encoding issues abi = true # D016-D017: ABI issues symbol = true # D006-D008, D019: symbol issues +state = true # D034: uninitialized register tracking +statements = true # D020: TODO/FIXME comments # Enable/disable severity levels [diagnostics.levels] diff --git a/package.json b/package.json index 29ef294..e4d51ff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "assembly-utils", "displayName": "assembly-utils", "description": "", - "version": "2.0.0", + "version": "2.0.1", "publisher": "babywolf", "repository": { "url": "https://github.com/fgsoftware1/assembly-utils-vscode" diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..716de72 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,27 @@ +# Binaries for programs and plugins +main + +*.exe +*.exe~ +*.so +*.dylib +*.dll + +# Ignore binary output folders +bin/ + +# Ignore common editor/system specific metadata +.DS_Store +.idea/ +.vscode/ +*.iml + +# ENV +.env + +# vweb and database +*.db +*.js + +# Ignore installed modules through `v install --local`: +modules/ diff --git a/src/config.v b/src/config.v index 08991bf..c3146e5 100644 --- a/src/config.v +++ b/src/config.v @@ -9,15 +9,16 @@ import os pub struct DiagCategories { pub mut: - size bool = true - truncation bool = true - register bool = true - symbol bool = true - directive bool = true - operand bool = true - encoding bool = true - abi bool = true - state bool = true + size bool = true + truncation bool = true + register bool = true + symbol bool = true + directive bool = true + operand bool = true + encoding bool = true + abi bool = true + state bool = true + statements bool = true } pub struct DiagLevels { @@ -206,6 +207,7 @@ fn apply(key string, value string, section string, mut cfg Config) { 'encoding' { cfg.diagnostics.categories.encoding = v } 'abi' { cfg.diagnostics.categories.abi = v } 'state' { cfg.diagnostics.categories.state = v } + 'statements' { cfg.diagnostics.categories.statements = v } else {} } } diff --git a/src/diagnostics.v b/src/diagnostics.v index 84209a9..16bb71a 100644 --- a/src/diagnostics.v +++ b/src/diagnostics.v @@ -315,7 +315,7 @@ pub fn (mut eng DiagEngine) publish_workspace() { fn (eng &DiagEngine) check_line(l Line, raw string, globals map[string]bool, path string) []Diag { mut diags := []Diag{} - // D031: incomplete label - line has label-like content but no colon + // D018 incomplete label - line has label-like content but no colon stripped := raw.trim_space() if stripped.len > 0 && !stripped.starts_with('.') && !stripped.starts_with('#') && !stripped.contains(':') { first_word := stripped.split(' ')[0].split(' ')[0] @@ -334,6 +334,18 @@ fn (eng &DiagEngine) check_line(l Line, raw string, globals map[string]bool, pat } } + // TODO check - comments containing TODO/FIXME/HACK/XXX + if eng.enabled('D020') && eng.cfg.diagnostics.categories.statements { + todo_patterns := ['TODO', 'FIXME', 'HACK', 'XXX', 'BUG'] + upper := stripped.to_upper() + for pattern in todo_patterns { + if upper.contains(pattern) { + diags << eng.make(l, raw, 'D020', .hint, "TODO comment found: '${pattern}' - consider addressing this") + break + } + } + } + if l.kind != .instruction && l.kind != .label_and_instruction { return diags } @@ -391,7 +403,7 @@ fn (eng &DiagEngine) check_size(l Line, raw string) []Diag { return diags } -// D004 truncation, D006 REX+high-byte, D021 32-bit mem in 64-bit +// D004 truncation, D005 REX+high-byte, D009 32-bit mem in 64-bit fn (eng &DiagEngine) check_operands(l Line, raw string) []Diag { mut diags := []Diag{} suffix_bits := if l.suffix != 0 { suffix_width(l.suffix) } else { 0 } @@ -418,7 +430,7 @@ fn (eng &DiagEngine) check_operands(l Line, raw string) []Diag { } } .memory { - // D021 — 32-bit base register in memory operand + // D009 — 32-bit base register in memory operand // crude check: look for (%e__) pattern if op.raw.contains('(%e') && eng.enabled('D009') { diags << eng.make(l, raw, 'D009', .error, "memory operand '${op.raw}' uses 32-bit base register in 64-bit mode; consider using the 64-bit equivalent") @@ -428,7 +440,7 @@ fn (eng &DiagEngine) check_operands(l Line, raw string) []Diag { } } - // D022 src == dst — check once per instruction + // D010 src == dst — check once per instruction if l.operands.len == 2 && l.mnemonic != 'xor' { src, dst := l.operands[0], l.operands[1] if src.kind == .register && dst.kind == .register && src.reg == dst.reg @@ -440,31 +452,31 @@ fn (eng &DiagEngine) check_operands(l Line, raw string) []Diag { return diags } -// D023 div-by-immediate, D025 invalid operand size, D026/D027 mul/imul, D028 shift count +// D011 div-by-immediate, D012 pushb, D013 one-operand imul, D014 mul unsigned, D015 shift count fn (eng &DiagEngine) check_encoding(l Line, raw string) []Diag { mut diags := []Diag{} match l.mnemonic { 'div', 'idiv' { - // D023 — div with immediate + // D011 — div with immediate if l.operands.any(it.kind == .immediate) && eng.enabled('D011') { diags << eng.make(l, raw, 'D011', .error, "'${l.mnemonic}' does not support immediate operands; load divisor into a register first") } } 'imul' { - // D026 — one-operand imul + // D013 — one-operand imul if l.operands.len == 1 && eng.enabled('D013') { diags << eng.make(l, raw, 'D013', .warning, "'imul' one-operand form: high half of result in rdx may be unexpected; did you want the two-operand form?") } } 'mul' { - // D027 — mul vs imul + // D014 — mul vs imul if eng.enabled('D014') { diags << eng.make(l, raw, 'D014', .warning, "'mul' is unsigned multiply; upper half stored in rdx may be silently discarded; use 'imul' if signed") } } 'shl', 'shr', 'sar' { - // D028 — shift count must be imm8 or %cl + // D015 — shift count must be imm8 or %cl if l.operands.len >= 1 { count := l.operands[0] if count.kind == .register && count.reg != 'cl' && eng.enabled('D015') { @@ -473,7 +485,7 @@ fn (eng &DiagEngine) check_encoding(l Line, raw string) []Diag { } } 'push' { - // D025 — pushb not encodable + // D012 — pushb not encodable if l.suffix == `b` && eng.enabled('D012') { diags << eng.make(l, raw, 'D012', .error, "'pushb' is not encodable; push only supports 16/32/64-bit operands") } @@ -484,7 +496,7 @@ fn (eng &DiagEngine) check_encoding(l Line, raw string) []Diag { return diags } -// D029 syscall clobber, D030 int $0x80 +// D016 syscall clobber, D017 int $0x80 fn (eng &DiagEngine) check_abi(l Line, raw string) []Diag { mut diags := []Diag{} if !eng.cfg.diagnostics.categories.abi { @@ -510,11 +522,11 @@ fn (eng &DiagEngine) check_abi(l Line, raw string) []Diag { return diags } -// D012 undefined symbol, D013 missing .global, D014 dead export, D015 duplicate +// D006 undefined symbol, D007 missing .global, D008 duplicate, D019 not exported fn (eng &DiagEngine) check_symbols(path string, lines []string, globals map[string]bool) []Diag { mut diags := []Diag{} - // D032: _start defined but not declared .global + // D019: _start defined but not declared .global mut has_start := false mut has_global_start := false for i, raw in lines { @@ -574,7 +586,7 @@ fn (eng &DiagEngine) check_symbols(path string, lines []string, globals map[stri continue } - // D013 — referenced cross-file but not .global + // D007 — referenced cross-file but not .global if found.file != path && found.vis == .local { if eng.enabled('D007') && eng.cfg.symbols.warn_missing_global { diags << Diag{ @@ -592,9 +604,9 @@ fn (eng &DiagEngine) check_symbols(path string, lines []string, globals map[stri } } - // D014 dead exports — requires cross-file reference tracking (TODO) +// D014 dead exports — requires cross-file reference tracking (TODO) - // D015 duplicate symbols +// D008 duplicate symbols for name, _ in globals { syms := eng.get_indexer().index.find_all(name) if syms.len > 1 && eng.enabled('D008') { diff --git a/src/tables/instrs.csv b/src/tables/instrs.csv index b96b872..bdd889f 100644 --- a/src/tables/instrs.csv +++ b/src/tables/instrs.csv @@ -84,3 +84,23 @@ cqo,,,"Sign-extend %rax into %rdx:%rax. Use before idivq.",none,"" lock,,,"Bus lock prefix. Makes the following RMW instruction atomic.",none,"Only valid with: add sub and or xor xchg cmpxchg inc dec neg not." rdtsc,,,"Read Time Stamp Counter. Result in %edx:%eax.",none,"%rcx clobbered. Not serializing." cpuid,,,"CPU identification. Input in %eax (leaf). Output in %eax/%ebx/%ecx/%edx.",none,"Serializing instruction." +cli,,,"Clear Interrupt Flag. Disables maskable interrupts.",none,"Ring 0 only. Use 'sti' to re-enable." +sti,,,"Set Interrupt Flag. Enables maskable interrupts.",none,"Ring 0 only. Must follow a 'cli' with actual I/O." +pusha,,,"Push all general-purpose registers.",none,"Pushes: ax, cx, dx, bx, sp, bp, si, di. Order is undefined." +popa,,,"Pop all general-purpose registers.",none,"Pops: di, si, bp, sp, bx, dx, cx, ax. Ignores the popped value for sp." +wait,,,"Check for and process pending unmasked exceptions.",none,"Also used with FPU." +iret,,,"Interrupt Return. Pops IP, CS, and flags.",none,"Return from interrupt handler." +lgdt,,mem,"Load Global Descriptor Table Register.",none,"Loads GDT from memory. Operand is 10-byte pointer." +lidt,,mem,"Load Interrupt Descriptor Table Register.",none,"Loads IDT from memory. Operand is 10-byte pointer." +ltr,,reg,"Load Task Register.",none,"Loads task register from TR. Also sets the busy bit." +sgdt,,mem,"Store Global Descriptor Table Register.",none,"Stores GDT to memory. Returns 10-byte pointer." +sidt,,mem,"Store Interrupt Descriptor Table Register.",none,"Stores IDT to memory. Returns 10-byte pointer." +str,,reg,"Store Task Register.",none,"Stores current task register." +rdmsr,,,"Read Model Specific Register. Input in %ecx.",none,"Output in %edx:%eax. Ring 0 only." +wrmsr,,,"Write Model Specific Register. Input in %ecx and %edx:%eax.",none,"Ring 0 only." +rdtscp,,,"Read Time Stamp Counter and Processor Info.",none,"Result in %edx:%eax. %rcx clobbered. Serializing." +movq,_,cr,"Move to/from Control Register.",none,"cr0, cr2, cr3, cr4, cr8. Ring 0 only." +movq,_,dr,"Move to/from Debug Register.",none,"dr0-dr7. Ring 0 only." +invlpg,,mem,"Invalidate TLB Entry.",none,"Flushes single TLB entry. Ring 0 only." +invd,,,"Invalidate all caches.",none,"Flushes all caches and TLBs. Ring 0 only." +wbinvd,,,"Write back and invalidate caches.",none,"Writes back then flushes. Ring 0 only." diff --git a/syntaxes/gas.tmLanguage.json b/syntaxes/gas.tmLanguage.json index fdfa419..7f972b6 100644 --- a/syntaxes/gas.tmLanguage.json +++ b/syntaxes/gas.tmLanguage.json @@ -90,7 +90,7 @@ "patterns": [ { "name": "keyword.instruction.assembly", - "match": "\\b(mov|add|sub|mul|imul|div|idiv|call|jmp|je|jne|ja|jb|jbe|jae|jg|jl|jle|jge|jo|jno|js|jns|jz|jnz|ret|push|pop|xchg|lea|cmp|test|and|or|xor|not|shl|shr|sar|sal|rol|ror|inc|dec|neg|nop|int|iret|hlt|cli|sti|lgdt|lldt|ltr|lgs|lfs|lss|leave|enter|loop|loope|loopne|loopz|loopnz|jcxz|jecxz|cpuid|rdmsr|wrmsr)(?:[bwlq])?\\b" + "match": "\\b(mov|add|sub|mul|imul|div|idiv|call|jmp|je|jne|ja|jb|jbe|jae|jg|jl|jle|jge|jo|jno|js|jns|jz|jnz|ret|push|pop|pusha|popa|xchg|lea|cmp|test|and|or|xor|not|shl|shr|sar|sal|rol|ror|inc|dec|neg|nop|int|iret|hlt|cli|sti|lgdt|lidt|ltr|sgdt|sidt|str|lgs|lfs|lss|leave|enter|loop|loope|loopne|loopz|loopnz|jcxz|jecxz|cpuid|rdmsr|wrmsr|rdtsc|rdtscp|cld|std|stc|clc|cmc|pushf|popf|cbw|cwde|cdqe|cwd|cdq|cqo|invlpg|invd|wbinvd)(?:[bwlq])?\\b" } ] }, diff --git a/tests/integration.sh b/tests/integration.sh new file mode 100755 index 0000000..e5fabb3 --- /dev/null +++ b/tests/integration.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +echo "=== gaslsp integration tests ===" + +LSP="${LSP:-$HOME/.local/bin/gaslsp}" + +test_init() { + echo "Testing initialize..." + resp=$(printf 'Content-Length: 81\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"rootUri":"file:///tmp"}}' | "$LSP" 2>/dev/null) + if ! echo "$resp" | grep -q '"capabilities"'; then + echo "FAIL: initialize" + exit 1 + fi + echo "PASS: initialize" +} + +test_shutdown() { + echo "Testing shutdown..." + init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"rootUri":"file:///tmp"}}' + shutdown='{"jsonrpc":"2.0","id":2,"method":"shutdown","params":null}' + exit='{"jsonrpc":"2.0","id":3,"method":"exit","params":null}' + + resp=$(printf "Content-Length: ${#init}\r\n\r\n${init}Content-Length: ${#shutdown}\r\n\r\n${shutdown}Content-Length: ${#exit}\r\n\r\n${exit}" | "$LSP" 2>/dev/null) + if ! echo "$resp" | grep -q '"capabilities"'; then + echo "FAIL: shutdown sequence" + echo "$resp" + exit 1 + fi + echo "PASS: shutdown" +} + +test_init +test_shutdown + +echo "" +echo "All tests passed!" diff --git a/tests/test_diags.sh b/tests/test_diags.sh new file mode 100755 index 0000000..bcff3dd --- /dev/null +++ b/tests/test_diags.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Test all diagnostic codes locally using the V test binary + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LSP="${LSP:-$HOME/.local/bin/gaslsp}" + +export LSP +export WORKSPACE + +"$SCRIPT_DIR/test_diags" \ No newline at end of file diff --git a/tests/test_diags.v b/tests/test_diags.v new file mode 100644 index 0000000..178fcc5 --- /dev/null +++ b/tests/test_diags.v @@ -0,0 +1,96 @@ +module main + +import os + +fn run_diag(lsp_path string, workspace string, code string, content string) bool { + tmpfile := os.join_path(workspace, 'tmp_${code}.s') + os.write_file(tmpfile, content) or { return false } + defer { + os.rm(tmpfile) or {} + } + + escaped_content := content.replace('\\', '\\\\').replace('"', '\\"') + escaped_tmpfile := tmpfile.replace('\\', '\\\\').replace('"', '\\"') + escaped_workspace := workspace.replace('\\', '\\\\').replace('"', '\\"') + + init_body := '{"jsonrpc":"2.0","method":"initialize","params":{"rootUri":"file://' + escaped_workspace + '"},"id":1}' + init_req := 'Content-Length: ${init_body.len}\r\n\r\n' + init_body + init_notif_body := '{"jsonrpc":"2.0","method":"initialized","params":{}}' + init_notif := 'Content-Length: ${init_notif_body.len}\r\n\r\n' + init_notif_body + open_body := '{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file://' + escaped_tmpfile + '","text":"' + escaped_content + '","version":1}},"id":2}' + open_req := 'Content-Length: ${open_body.len}\r\n\r\n' + open_body + shutdown_body := '{"jsonrpc":"2.0","method":"shutdown","params":{},"id":3}' + shutdown_req := 'Content-Length: ${shutdown_body.len}\r\n\r\n' + shutdown_body + exit_body := '{"jsonrpc":"2.0","method":"exit","params":{}}' + exit_notif := 'Content-Length: ${exit_body.len}\r\n\r\n' + exit_body + + input := init_req + init_notif + open_req + shutdown_req + exit_notif + + tmp_input := os.join_path(workspace, 'tmp_input.txt') + os.write_file(tmp_input, input) or { return false } + defer { + os.rm(tmp_input) or {} + } + + tmp_output := os.join_path(workspace, 'tmp_output.txt') + bash_cmd := '/bin/bash -c \'cat "' + tmp_input + '" | "' + lsp_path + '" > "' + tmp_output + '"\'' + os.execute(bash_cmd) + + tmp_output_content := os.read_file(tmp_output) or { + return false + } + + if tmp_output_content.contains(code) && tmp_output_content.contains('publishDiagnostics') { + return true + } + return false +} + +fn main() { + lsp_env := os.getenv('LSP') + lsp_path := if lsp_env.len > 0 { lsp_env } else { os.join_path(os.home_dir(), '.local', 'bin', 'gaslsp') } + workspace_env := os.getenv('WORKSPACE') + workspace := if workspace_env.len > 0 { workspace_env } else { os.getwd() } + + mut passed := 0 + mut failed := 0 + + tests := { + 'D001': 'mov $1, %rax' + 'D002': 'mov %eax, %ebx' + 'D003': 'movl %eax, %rax' + 'D004': 'movb $256, %al' + 'D005': 'mov %ah, %r8' + 'D009': 'mov (%eax), %eax' + 'D010': 'add %eax, %eax' + 'D011': 'div $4' + 'D012': 'pushb $42' + 'D013': 'imul %eax' + 'D014': 'mul %eax' + 'D015': 'shl %eax, %ebx' + 'D016': 'syscall' + 'D017': 'int $0x80' + 'D018': 'mylabel' + 'D020': '# TODO: fix this' + } + + println('=== Testing all diagnostic codes ===') + + for code, content in tests { + print('Testing ${code}... ') + if run_diag(lsp_path, workspace, code, content) { + println('PASS') + passed++ + } else { + println('FAIL') + failed++ + } + } + + println('') + println('=== Tests complete: ${passed} passed, ${failed} failed ===') + + if failed > 0 { + exit(1) + } +} \ No newline at end of file