Skip to content

Commit 625db86

Browse files
committed
Added lint command
Signed-off-by: Ole Herman Schumacher Elgesem <ole.elgesem@northern.tech>
1 parent fcdc2d8 commit 625db86

File tree

4 files changed

+199
-1
lines changed

4 files changed

+199
-1
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,41 @@ cfengine help
3535
cfengine run
3636
```
3737

38+
Not implemented yet - TODOs:
39+
40+
- The command could automatically detect that you have CFEngine installed on a
41+
remote hub, and run it there instead (using cf-remote)
42+
- Handle when `cf-agent` is not installed, help users install.
43+
- Prompt / help users do what they meant (i.e. build and deploy and run).
44+
3845
### Automatically format source code
3946

4047
```bash
4148
cfengine format
4249
```
4350

51+
Not implemented yet - TODOs:
52+
53+
- Automatically break up and indent method calls, function calls, and nested function calls.
54+
- Smarter placement of comments based on context.
55+
- The command should be able to take a filename as an argument, and also operate using stdin and stdout.
56+
(Receive file content on stdin, file type using command line arg, output formatted file to stdout).
57+
58+
### Check for errors in source code
59+
60+
```bash
61+
cfengine lint
62+
```
63+
64+
Note that since we use a different parser than `cf-agent` / `cf-promises`, they are not 100% in sync.
65+
`cf-agent` could point out something as a syntax error, while `cfengine lint` does not and vice versa.
66+
We aim to make the tree-sitter parser (used in this tool) more strict in general, so that when `cfengine lint` is happy with your policy, `cf-agent` will also accept it.
67+
(But the opposite is not a goal, that `cfengine lint` must accept any policy `cf-agent` would find acceptable).
68+
69+
Not implemented yet - TODOs:
70+
71+
- The command should be able to take a filename as an argument, and also take file content from stdin
72+
4473
## Supported platforms and versions
4574

4675
This tool will only support a limited number of platforms, it is not int.

src/cfengine_cli/commands.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
import os
2+
from cfengine_cli.lint import lint_cfbs_json, lint_json, lint_policy_file
13
from cfengine_cli.shell import user_command
24
from cfengine_cli.paths import bin
35
from cfengine_cli.version import cfengine_cli_version_string
46
from cfengine_cli.format import format_policy_file, format_json_file
5-
from cfbs.utils import find
7+
from cfbs.utils import find, user_error
8+
9+
10+
def require_cfagent():
11+
if not os.path.exists(bin("cf-agent")):
12+
user_error(f"cf-agent not found at {bin('cf-agent')}")
13+
14+
15+
def require_cfhub():
16+
if not os.path.exists(bin("cf-hub")):
17+
user_error(f"cf-hub not found at {bin('cf-hub')}")
618

719

820
def format() -> int:
@@ -17,14 +29,37 @@ def format() -> int:
1729
return 0
1830

1931

32+
def lint() -> int:
33+
errors = 0
34+
for filename in find(".", extension=".json"):
35+
if filename.startswith("./."):
36+
continue
37+
if filename.endswith("/cfbs.json"):
38+
lint_cfbs_json(filename)
39+
continue
40+
errors += lint_json(filename)
41+
42+
for filename in find(".", extension=".cf"):
43+
if filename.startswith("./."):
44+
continue
45+
errors += lint_policy_file(filename)
46+
47+
if errors == 0:
48+
return 0
49+
return 1
50+
51+
2052
def report() -> int:
53+
require_cfhub()
54+
require_cfagent()
2155
user_command(f"{bin('cf-agent')} -KIf update.cf && {bin('cf-agent')} -KI")
2256
user_command(f"{bin('cf-hub')} --query rebase -H 127.0.0.1")
2357
user_command(f"{bin('cf-hub')} --query delta -H 127.0.0.1")
2458
return 0
2559

2660

2761
def run() -> int:
62+
require_cfagent()
2863
user_command(f"{bin('cf-agent')} -KIf update.cf && {bin('cf-agent')} -KI")
2964
return 0
3065

src/cfengine_cli/lint.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Linting of CFEngine related files.
3+
4+
Currently implemented for:
5+
- *.cf (policy files)
6+
- cfbs.json (CFEngine Build project files)
7+
- *.json (basic JSON syntax checking)
8+
9+
Usage:
10+
$ cfengine lint
11+
12+
TODOS:
13+
- It would be nice if we refactored validate_config in cfbs
14+
so it would take a simple dictionary (JSON) instead of a
15+
special CFBSConfig object.
16+
"""
17+
18+
import sys
19+
import os
20+
import json
21+
import tree_sitter_cfengine as tscfengine
22+
from tree_sitter import Language, Parser, Node, Tree
23+
from cfbs.validate import validate_config
24+
from cfbs.cfbs_config import CFBSConfig
25+
26+
27+
def lint_cfbs_json(filename):
28+
assert os.path.isfile(filename)
29+
assert filename.endswith("cfbs.json")
30+
31+
config = CFBSConfig.get_instance(filename=filename, non_interactive=True)
32+
r = validate_config(config)
33+
34+
if r == 0:
35+
print(f"PASS: {filename}")
36+
return
37+
print(f"FAIL: {filename}")
38+
39+
40+
def lint_json(filename):
41+
assert os.path.isfile(filename)
42+
with open(filename, "r") as f:
43+
data = f.read()
44+
45+
try:
46+
data = json.loads(data)
47+
except:
48+
print(f"FAIL: {filename} (invalid JSON)")
49+
return 1
50+
print(f"PASS: {filename}")
51+
return 0
52+
53+
54+
def _highlight_range(node, lines):
55+
line = node.range.start_point[0] + 1
56+
column = node.range.start_point[1]
57+
58+
length = len(lines[line - 1]) - column
59+
if node.range.start_point[0] == node.range.end_point[0]:
60+
# Starts and ends on same line:
61+
length = node.range.end_point[1] - node.range.start_point[1]
62+
assert length >= 1
63+
print("")
64+
if line >= 2:
65+
print(lines[line - 2])
66+
print(lines[line - 1])
67+
marker = "^"
68+
if length > 2:
69+
marker += "-" * (length - 2)
70+
if length > 1:
71+
marker += "^"
72+
print(" " * column + marker)
73+
74+
75+
def _text(node):
76+
return node.text.decode()
77+
78+
79+
def _walk(filename, lines, node) -> int:
80+
line = node.range.start_point[0] + 1
81+
column = node.range.start_point[1]
82+
errors = 0
83+
# Checking for syntax errors (already detected by parser / grammar).
84+
# These are represented in the syntax tree as special ERROR nodes.
85+
if node.type == "ERROR":
86+
_highlight_range(node, lines)
87+
print(f"Error: Syntax error at {filename}:{line}:{column}")
88+
errors += 1
89+
90+
if node.type == "attribute_name":
91+
if _text(node) == "ifvarclass":
92+
_highlight_range(node, lines)
93+
print(
94+
f"Error: Use 'if' instead of 'ifvarclass' (deprecated) at {filename}:{line}:{column}"
95+
)
96+
errors += 1
97+
98+
for node in node.children:
99+
errors += _walk(filename, lines, node)
100+
101+
return errors
102+
103+
104+
def lint_policy_file(filename):
105+
assert os.path.isfile(filename)
106+
assert filename.endswith(".cf")
107+
PY_LANGUAGE = Language(tscfengine.language())
108+
parser = Parser(PY_LANGUAGE)
109+
110+
with open(filename, "rb") as f:
111+
original_data = f.read()
112+
tree = parser.parse(original_data)
113+
lines = original_data.decode().split("\n")
114+
115+
root_node = tree.root_node
116+
assert root_node.type == "source_file"
117+
errors = 0
118+
if not root_node.children:
119+
print(f"Error: Empty policy file '{filename}'")
120+
errors += 1
121+
errors += _walk(filename, lines, root_node)
122+
if errors == 0:
123+
print(f"PASS: {filename}")
124+
return 0
125+
126+
print(f"FAIL: {filename} ({errors} error{'s' if errors > 0 else ''})")
127+
return errors

src/cfengine_cli/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ def _get_arg_parser():
3838

3939
subp.add_parser("format", help="Autoformat .json and .cf files")
4040

41+
subp.add_parser(
42+
"lint",
43+
help="Look for syntax errors and other simple mistakes",
44+
)
45+
4146
subp.add_parser(
4247
"report",
4348
help="Run the agent and hub commands necessary to get new reporting data",
@@ -70,6 +75,8 @@ def run_command_with_args(command, _) -> int:
7075
return commands.version()
7176
if command == "format":
7277
return commands.format()
78+
if command == "lint":
79+
return commands.lint()
7380
if command == "report":
7481
return commands.report()
7582
if command == "run":

0 commit comments

Comments
 (0)