Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions tools/dm-lint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# DM Lint — DreamMaker Code Linter

Validates .dm source files without the DreamMaker compiler.

## Usage
```bash
python tools/dm-lint/dmlinter.py code/
```

## Rules
- Tab character detection (use spaces)
- Brace matching (unclosed blocks)
- Parenthesis matching
- Unclosed block comments
- Empty proc body warnings

## Tests
```bash
python tools/dm-lint/test_dmlinter.py
```
141 changes: 141 additions & 0 deletions tools/dm-lint/dmlinter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""DreamMaker (.dm) Linter — validates SS13 code without the DM compiler.

Usage: python dmlinter.py code/
"""

import re
import sys
import os
from pathlib import Path
from dataclasses import dataclass
from typing import List

@dataclass
class Diagnostic:
file: str
line: int
col: int
severity: str # error, warning
message: str

class DMLinter:
def __init__(self):
self.diagnostics: List[Diagnostic] = []
self.current_file = ""

def discover_files(self, root: str) -> List[Path]:
"""Recursively find all .dm files."""
return sorted(Path(root).rglob("*.dm"))

def lint_file(self, path: Path):
self.current_file = str(path)
try:
lines = path.read_text(encoding='utf-8', errors='replace').split('\n')
except Exception as e:
self.diag(1, 1, 'error', f'Cannot read file: {e}')
return

in_block_comment = False
brace_depth = 0
paren_depth = 0

for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped:
continue

# Skip block comments
if in_block_comment:
if '*/' in stripped:
in_block_comment = False
continue
if stripped.startswith('/*'):
if '*/' not in stripped:
in_block_comment = True
continue

# Skip single-line comments
if stripped.startswith('//'):
continue

# Rule 1: Check for tabs (DM uses spaces, tabs cause issues)
if '\t' in line and not stripped.startswith('#define'):
self.diag(i, line.index('\t') + 1, 'warning', 'Tab character found; use spaces for indentation')

# Rule 2: Check proc definitions have closing braces
if re.match(r'/(proc|obj|mob|turf|area|datum|list)/', stripped):
if '{' not in stripped:
self.diag(i, 1, 'warning', f'Type/proc definition missing opening brace')

# Rule 3: Brace matching
brace_depth += stripped.count('{') - stripped.count('}')
if brace_depth < 0:
self.diag(i, 1, 'error', f'Unexpected closing brace (depth went negative)')
brace_depth = 0

# Rule 4: Check for missing semicolons where expected (heuristic)
# Rule 5: var/ declarations should have a type or value
if re.match(r'var/\w+\s*$', stripped) and '=' not in stripped:
# Might be a multi-line var - check next line
pass

# Rule 6: Check for common DM mistakes
if stripped.count('(') != stripped.count(')'):
# Might span lines, track
paren_depth += stripped.count('(') - stripped.count(')')

# Rule 7: spawn() and sleep() need proper context
if re.search(r'\bspawn\s*\(', stripped) and 'set waitfor = FALSE' not in line:
pass # Not necessarily an error

# Rule 8: Check for empty proc bodies
if re.match(r'/proc/\w+\([^)]*\)\s*$', stripped):
if i + 1 < len(lines) and lines[i].strip() == '':
self.diag(i, 1, 'warning', 'Empty proc body? Proc definition followed by blank line')

# End of file checks
if brace_depth != 0:
self.diag(len(lines), 1, 'error', f'Unclosed braces at end of file (depth: {brace_depth})')
if paren_depth != 0:
self.diag(len(lines), 1, 'error', f'Unclosed parentheses at end of file (depth: {paren_depth})')
if in_block_comment:
self.diag(len(lines), 1, 'error', 'Unclosed block comment at end of file')

def diag(self, line: int, col: int, severity: str, message: str):
self.diagnostics.append(Diagnostic(self.current_file, line, col, severity, message))

def run(self, root: str) -> int:
files = self.discover_files(root)
if not files:
print(f"dm-lint: No .dm files found in {root}", file=sys.stderr)
return 1

for f in files:
self.lint_file(f)

# Report
errors = [d for d in self.diagnostics if d.severity == 'error']
warnings = [d for d in self.diagnostics if d.severity == 'warning']

for d in sorted(self.diagnostics, key=lambda x: (x.file, x.line)):
print(f"{d.file}:{d.line}:{d.col}: {d.severity}: {d.message}")

print(f"\n{d.name()}: {len(files)} files, {len(errors)} errors, {len(warnings)} warnings")

return 1 if errors else 0

@staticmethod
def name():
return "dm-lint"

def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <code_directory>", file=sys.stderr)
sys.exit(2)

linter = DMLinter()
sys.exit(linter.run(sys.argv[1]))

if __name__ == "__main__":
main()
55 changes: 55 additions & 0 deletions tools/dm-lint/test_dmlinter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Tests for DM Linter."""
import unittest
import tempfile
import os
from pathlib import Path
from dmlinter import DMLinter

class TestDMLinter(unittest.TestCase):
def setUp(self):
self.linter = DMLinter()
self.tmpdir = tempfile.mkdtemp()

def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)

def write_dm(self, name, content):
path = Path(self.tmpdir) / name
path.write_text(content)
return path

def test_discovers_dm_files(self):
self.write_dm("test.dm", "/proc/test()\n\treturn")
self.write_dm("readme.txt", "not dm")
files = self.linter.discover_files(self.tmpdir)
self.assertEqual(len(files), 1)
self.assertTrue(str(files[0]).endswith("test.dm"))

def test_detects_tab_indentation(self):
self.write_dm("tabs.dm", "/proc/test()\n\tvar/x = 1\n\treturn x")
self.linter.lint_file(Path(self.tmpdir) / "tabs.dm")
tabs = [d for d in self.linter.diagnostics if 'Tab' in d.message]
self.assertGreater(len(tabs), 0)

def test_unclosed_brace(self):
self.write_dm("bad.dm", "/proc/test()\n\tvar/x = 1\n\tif(x) {\n\t\treturn\n")
self.linter.lint_file(Path(self.tmpdir) / "bad.dm")
braces = [d for d in self.linter.diagnostics if 'brace' in d.message.lower()]
self.assertGreater(len(braces), 0)

def test_valid_file_no_errors(self):
self.write_dm("good.dm", "/proc/test()\n\tvar/x = 1\n\tif(x) {\n\t\treturn x\n\t}\n\treturn 0\n")
self.linter.lint_file(Path(self.tmpdir) / "good.dm")
errors = [d for d in self.linter.diagnostics if d.severity == 'error']
self.assertEqual(len(errors), 0)

def test_empty_proc_warning(self):
self.write_dm("empty.dm", "/proc/empty()\n\n/obj/thing\n\tvar/name = \"thing\"\n")
self.linter.lint_file(Path(self.tmpdir) / "empty.dm")
warnings = [d for d in self.linter.diagnostics if d.severity == 'warning']
self.assertGreater(len(warnings), 0)

if __name__ == "__main__":
unittest.main()
Loading