linti 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. linti/__init__.py +9 -0
  2. linti/cli/__init__.py +1 -0
  3. linti/cli/auto_fixer.py +218 -0
  4. linti/cli/config_loader.py +56 -0
  5. linti/cli/file_linter.py +280 -0
  6. linti/cli/issue_reporter.py +210 -0
  7. linti/cli/main.py +130 -0
  8. linti/cli/rule_explainer.py +126 -0
  9. linti/config.py +246 -0
  10. linti/lexer/__init__.py +0 -0
  11. linti/lexer/lexer.py +317 -0
  12. linti/lexer/token.py +151 -0
  13. linti/lexer/token_window.py +31 -0
  14. linti/linter/__init__.py +0 -0
  15. linti/linter/lint_context.py +72 -0
  16. linti/linter/lint_issue.py +27 -0
  17. linti/linter/linter.py +115 -0
  18. linti/linter/noqa.py +201 -0
  19. linti/loader/__init__.py +35 -0
  20. linti/loader/base.py +83 -0
  21. linti/loader/ti_loader.py +41 -0
  22. linti/loader/yaml_loader.py +297 -0
  23. linti/parser/ast.py +205 -0
  24. linti/parser/parser.py +605 -0
  25. linti/rules/Rule.py +157 -0
  26. linti/rules/__init__.py +15 -0
  27. linti/rules/documentation/__init__.py +1 -0
  28. linti/rules/documentation/docstring_region_rule.py +222 -0
  29. linti/rules/format/__init__.py +0 -0
  30. linti/rules/format/indentation_rule.py +181 -0
  31. linti/rules/format/keyword_casing_rule.py +195 -0
  32. linti/rules/format/newline_per_statement_rule.py +88 -0
  33. linti/rules/format/no_multiple_spaces_rule.py +67 -0
  34. linti/rules/format/no_space_before_semicolon_rule.py +76 -0
  35. linti/rules/format/no_trailing_whitespace_rule.py +65 -0
  36. linti/rules/format/one_space_inside_parentheses_rule.py +122 -0
  37. linti/rules/format/whitespace_after_comma_rule.py +84 -0
  38. linti/rules/format/whitespace_around_operators_rule.py +119 -0
  39. linti/rules/naming/__init__.py +0 -0
  40. linti/rules/naming/naming_rule.py +215 -0
  41. linti/rules/naming/parameter_naming_rule.py +104 -0
  42. linti/rules/naming/variable_naming_rule.py +49 -0
  43. linti/rules/rule_factory.py +118 -0
  44. linti/rules/semantic/__init__.py +0 -0
  45. linti/rules/semantic/constant_rule.py +83 -0
  46. linti/rules/semantic/docstring_region_rule.py +10 -0
  47. linti/rules/semantic/execute_command_rule.py +71 -0
  48. linti/rules/semantic/item_skip_rule.py +80 -0
  49. linti/rules/semantic/odbc_open_parameter_rule.py +114 -0
  50. linti/rules/semantic/process_call_literal_rule.py +79 -0
  51. linti/rules/semantic/process_quit_rule.py +131 -0
  52. linti/rules/semantic/readonly_parameter_variable_rule.py +110 -0
  53. linti/semantic/type_inference.py +103 -0
  54. linti-0.4.2.dist-info/METADATA +323 -0
  55. linti-0.4.2.dist-info/RECORD +59 -0
  56. linti-0.4.2.dist-info/WHEEL +5 -0
  57. linti-0.4.2.dist-info/entry_points.txt +2 -0
  58. linti-0.4.2.dist-info/licenses/LICENSE +202 -0
  59. linti-0.4.2.dist-info/top_level.txt +1 -0
linti/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from linti.lexer.lexer import Lexer
2
+ from linti.linter.linter import Linter
3
+ from linti.rules.Rule import BaseRule
4
+
5
+ __all__ = [
6
+ "BaseRule",
7
+ "Lexer",
8
+ "Linter",
9
+ ]
linti/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI module for linti."""
@@ -0,0 +1,218 @@
1
+ """Auto-fixing functionality for TM1 linter."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from linti.lexer.lexer import Lexer
7
+ from linti.linter.lint_context import LintContext
8
+ from linti.linter.lint_issue import LintIssue
9
+ from linti.linter.linter import Linter
10
+
11
+ MAX_AUTO_FIX_PASSES = 10
12
+
13
+
14
+ def apply_fixes(code: str, issues: list[LintIssue]) -> tuple[str, int]:
15
+ """
16
+ Apply all fixable issues to code.
17
+
18
+ Replaces text at positions specified by each issue's Fix object.
19
+ Works with any rule that provides a Fix — no rule-specific logic needed.
20
+
21
+ Args:
22
+ code: The original source code
23
+ issues: List of LintIssue objects (only those with a fix are applied)
24
+
25
+ Returns:
26
+ Tuple of (fixed_code, num_fixes_applied)
27
+ """
28
+ fixable = [issue for issue in issues if issue.fix is not None]
29
+
30
+ if not fixable:
31
+ return code, 0
32
+
33
+ # Sort fixes from back to front.
34
+ # At the same position, apply replacements before insertions.
35
+ fixable.sort(key=lambda i: (-i.fix.position, len(i.fix.old_value) == 0))
36
+
37
+ fixed_code = code
38
+ for issue in fixable:
39
+ fix = issue.fix
40
+ start = fix.position
41
+ end = start + len(fix.old_value)
42
+
43
+ # Verify the old value is at the expected position
44
+ if fixed_code[start:end] == fix.old_value:
45
+ fixed_code = fixed_code[:start] + fix.new_value + fixed_code[end:]
46
+
47
+ return fixed_code, len(fixable)
48
+
49
+
50
+ def collect_fixable_issues(
51
+ code: str, linter: Linter, lint_context: Optional[LintContext] = None
52
+ ) -> list[LintIssue]:
53
+ """
54
+ Lint code and return only the fixable issues.
55
+
56
+ Args:
57
+ code: The TI code to analyze
58
+ linter: Configured linter instance
59
+ lint_context: Optional LintContext
60
+
61
+ Returns:
62
+ List of LintIssue objects that have a Fix
63
+ """
64
+ tokens = Lexer(code).tokenize()
65
+ issues = linter.lint(tokens, lint_context)
66
+ return [issue for issue in issues if issue.fix is not None]
67
+
68
+
69
+ def apply_fixes_iteratively(
70
+ code: str,
71
+ linter: Linter,
72
+ lint_context: Optional[LintContext] = None,
73
+ max_passes: int = MAX_AUTO_FIX_PASSES,
74
+ ) -> tuple[str, int]:
75
+ """Apply fixable issues repeatedly until no more fixes remain.
76
+
77
+ This is needed when one fix creates the structure required for later fixes,
78
+ for example splitting statements onto new lines before indentation can be
79
+ computed correctly.
80
+
81
+ Args:
82
+ code: The original source code
83
+ linter: Configured linter instance
84
+ lint_context: Optional lint context
85
+ max_passes: Safety limit to avoid infinite fix loops
86
+
87
+ Returns:
88
+ Tuple of (fixed_code, total_num_fixes_applied)
89
+ """
90
+ fixed_code = code
91
+ total_fixes = 0
92
+
93
+ for _ in range(max_passes):
94
+ issues = collect_fixable_issues(fixed_code, linter, lint_context)
95
+ if not issues:
96
+ break
97
+
98
+ next_code, num_fixes = apply_fixes(fixed_code, issues)
99
+ if num_fixes == 0 or next_code == fixed_code:
100
+ break
101
+
102
+ fixed_code = next_code
103
+ total_fixes += num_fixes
104
+
105
+ return fixed_code, total_fixes
106
+
107
+
108
+ def auto_fix_ti_file(
109
+ file_path: Path, linter: Linter, lint_context: Optional[LintContext] = None
110
+ ) -> int:
111
+ """
112
+ Auto-fix a TI file in place.
113
+
114
+ Args:
115
+ file_path: Path to the TI file
116
+ linter: Configured linter instance
117
+ lint_context: Optional LintContext
118
+
119
+ Returns:
120
+ Number of fixes applied
121
+ """
122
+ with open(file_path, "r") as f:
123
+ original_code = f.read()
124
+
125
+ fixed_code, num_fixes = apply_fixes_iteratively(original_code, linter, lint_context)
126
+
127
+ if num_fixes > 0:
128
+ with open(file_path, "w") as f:
129
+ f.write(fixed_code)
130
+
131
+ return num_fixes
132
+
133
+
134
+ def auto_fix_yaml_procedures(
135
+ file_path: Path, process, linter: Linter
136
+ ) -> dict[str, int]:
137
+ """
138
+ Auto-fix procedures in a YAML ProcessObject file.
139
+
140
+ Args:
141
+ file_path: Path to the YAML file
142
+ process: TM1Process with procedures
143
+ linter: Configured linter instance
144
+
145
+ Returns:
146
+ Dict mapping procedure names to number of fixes applied
147
+ """
148
+ from linti.loader.base import extract_procedures
149
+
150
+ procedures = extract_procedures(process)
151
+ fixes_by_proc = {}
152
+
153
+ # Read original YAML content
154
+ with open(file_path, "r") as f:
155
+ yaml_lines = f.readlines()
156
+
157
+ indent_prefix = " " * process.content_indent
158
+
159
+ # Process each procedure
160
+ for proc_name, proc_info in procedures.items():
161
+ code = proc_info.code
162
+ yaml_start_line = proc_info.source_line
163
+
164
+ # Create context for this procedure
165
+ lint_ctx = LintContext(
166
+ block=proc_name,
167
+ parameters=process.parameters,
168
+ parameter_lines=process.parameter_lines,
169
+ variables=process.variables,
170
+ variable_lines=process.variable_lines,
171
+ block_start_line=proc_info.source_line,
172
+ block_end_line=proc_info.source_end_line,
173
+ )
174
+
175
+ # Apply fixes
176
+ fixed_code, num_fixes = apply_fixes_iteratively(code, linter, lint_ctx)
177
+
178
+ if num_fixes > 0:
179
+ fixes_by_proc[proc_name] = num_fixes
180
+
181
+ # Replace the procedure content in YAML
182
+ # yaml_start_line points to where the procedure content starts (after "ProcedureName: |-")
183
+ # We need to find where the content ends (next line at lower indent or end of file)
184
+
185
+ content_start = yaml_start_line # This is 1-based line number
186
+ content_start_idx = content_start - 1 # Convert to 0-based index
187
+
188
+ # Find end of content (next line not indented at the content level or end of file)
189
+ content_end_idx = content_start_idx
190
+ for j in range(content_start_idx, len(yaml_lines)):
191
+ line = yaml_lines[j]
192
+ # If line has content and is not indented at (or beyond) the content level, we've hit the end
193
+ if line.strip() and not line.startswith(indent_prefix):
194
+ content_end_idx = j
195
+ break
196
+ else:
197
+ content_end_idx = len(yaml_lines)
198
+
199
+ # Replace the content with fixed code
200
+ fixed_lines = []
201
+ for line in fixed_code.split("\n"):
202
+ if not line and not fixed_lines:
203
+ # Skip leading empty lines
204
+ continue
205
+ if line:
206
+ fixed_lines.append(f"{indent_prefix}{line}\n")
207
+ else:
208
+ # Preserve blank lines without adding trailing spaces
209
+ fixed_lines.append("\n")
210
+
211
+ yaml_lines[content_start_idx:content_end_idx] = fixed_lines
212
+
213
+ # Write back the modified YAML
214
+ if fixes_by_proc:
215
+ with open(file_path, "w") as f:
216
+ f.writelines(yaml_lines)
217
+
218
+ return fixes_by_proc
@@ -0,0 +1,56 @@
1
+ """Configuration loading with CLI output."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from linti.config import Config
9
+
10
+
11
+ def load_config(file_path: Path, config_override: Optional[Path] = None) -> Config:
12
+ """
13
+ Load configuration from file or discover default.
14
+
15
+ Args:
16
+ file_path: Path to a TI file, YAML file, or directory being linted
17
+ config_override: Optional explicit config file path
18
+
19
+ Returns:
20
+ Loaded Config object
21
+ """
22
+ if config_override:
23
+ cfg = Config.load_from_file(config_override)
24
+ typer.echo(f"Loaded config from: {config_override}")
25
+ return cfg
26
+
27
+ if file_path.is_dir():
28
+ # For directories, walk upward from the directory itself
29
+ target_sentinel = file_path / "_dummy"
30
+ cfg = Config.find_and_load(target_sentinel)
31
+ # Check if a config was actually found (walk upward)
32
+ directory = file_path.resolve()
33
+ while True:
34
+ config_file = directory / "linti.yaml"
35
+ if config_file.exists():
36
+ typer.echo(f"Loaded config from: {config_file}")
37
+ break
38
+ parent = directory.parent
39
+ if parent == directory:
40
+ break
41
+ directory = parent
42
+ return cfg
43
+
44
+ # For files, walk upward from the file's directory
45
+ cfg = Config.find_and_load(file_path)
46
+ directory = file_path.parent.resolve()
47
+ while True:
48
+ config_file = directory / "linti.yaml"
49
+ if config_file.exists():
50
+ typer.echo(f"Loaded config from: {config_file}")
51
+ break
52
+ parent = directory.parent
53
+ if parent == directory:
54
+ break
55
+ directory = parent
56
+ return cfg
@@ -0,0 +1,280 @@
1
+ """File linting operations for TI files, YAML files, and directories."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from linti.cli.auto_fixer import (
9
+ auto_fix_ti_file,
10
+ auto_fix_yaml_procedures,
11
+ )
12
+ from linti.cli.config_loader import load_config
13
+ from linti.cli.issue_reporter import (
14
+ report_directory_issues,
15
+ report_issues,
16
+ report_yaml_issues,
17
+ )
18
+ from linti.lexer.lexer import Lexer
19
+ from linti.linter.lint_context import LintContext
20
+ from linti.linter.linter import Linter
21
+ from linti.loader.base import extract_procedures
22
+ from linti.loader.yaml_loader import load_yaml_process
23
+ from linti.parser.ast import UnknownStatement
24
+ from linti.parser.parser import Parser
25
+ from linti.rules.rule_factory import create_rules
26
+
27
+
28
+ def analyze_and_lint_code(
29
+ code: str,
30
+ linter: Linter,
31
+ show_tokens: bool,
32
+ show_ast: bool,
33
+ context_label: Optional[str] = None,
34
+ lint_context: Optional[LintContext] = None,
35
+ ) -> list:
36
+ """
37
+ Tokenize, parse, and lint code.
38
+
39
+ Args:
40
+ code: The TI code to analyze
41
+ linter: Configured linter instance
42
+ show_tokens: Whether to display tokens
43
+ show_ast: Whether to display AST
44
+ context_label: Optional context label for output (e.g., procedure name)
45
+ lint_context: Optional LintContext with block, parameters, variables
46
+
47
+ Returns:
48
+ List of linting issues
49
+ """
50
+ lexer = Lexer(code)
51
+ tokens = lexer.tokenize()
52
+
53
+ if show_tokens:
54
+ label = f" ({context_label})" if context_label else ""
55
+ typer.echo(f"\nTokens{label}:")
56
+ for token in tokens:
57
+ if token.type.name not in ["WHITESPACE", "NEWLINE"]:
58
+ typer.echo(f"{token.type.name:15} {token.value!r}")
59
+
60
+ # Build AST
61
+ parser = Parser(tokens)
62
+ ast = parser.parse()
63
+
64
+ if show_ast:
65
+ label = f" ({context_label})" if context_label else ""
66
+ typer.echo(f"\nAST{label}:")
67
+ typer.echo(f"Program with {len(ast.statements)} statements:")
68
+ for i, stmt in enumerate(ast.statements, 1):
69
+ stmt_name = stmt.__class__.__name__
70
+ if isinstance(stmt, UnknownStatement):
71
+ typer.echo(
72
+ f" {i}. {stmt_name} (error: {stmt.error_message})",
73
+ err=True,
74
+ )
75
+ else:
76
+ typer.echo(f" {i}. {stmt_name}")
77
+
78
+ # Run linter and return issues
79
+ return linter.lint(tokens, lint_context, ast=ast)
80
+
81
+
82
+ def lint_ti_file(
83
+ file_path: Path,
84
+ show_tokens: bool,
85
+ show_ast: bool,
86
+ config: Optional[Path],
87
+ auto_fix: bool = False,
88
+ select: Optional[str] = None,
89
+ ) -> None:
90
+ """
91
+ Lint a TI (.ti) file.
92
+
93
+ Args:
94
+ file_path: Path to the TI file
95
+ show_tokens: Whether to display tokens
96
+ show_ast: Whether to display AST
97
+ config: Optional config file path
98
+ auto_fix: Whether to automatically fix supported auto-fixable issues
99
+ select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
100
+
101
+ Raises:
102
+ typer.Exit: With appropriate exit code
103
+ """
104
+ cfg = load_config(file_path, config)
105
+ token_rules, statement_rules = create_rules(cfg, select=select)
106
+ linter = Linter(rules=token_rules, statement_rules=statement_rules)
107
+
108
+ if auto_fix:
109
+ # Apply auto-fixes
110
+ num_fixes = auto_fix_ti_file(file_path, linter, lint_context=None)
111
+ if num_fixes > 0:
112
+ typer.echo(f"Fixed {num_fixes} auto-fixable issue(s) in {file_path}")
113
+ else:
114
+ typer.echo(f"No auto-fixable issues to fix in {file_path}")
115
+
116
+ # Read the file (potentially after fixes have been applied)
117
+ with open(file_path, "r") as f:
118
+ ti_code = f.read()
119
+
120
+ issues = analyze_and_lint_code(
121
+ ti_code,
122
+ linter,
123
+ show_tokens,
124
+ show_ast,
125
+ context_label=None,
126
+ lint_context=LintContext(block="prolog", process_name=file_path.stem),
127
+ )
128
+
129
+ raise typer.Exit(code=report_issues(file_path, issues))
130
+
131
+
132
+ def lint_yaml_file(
133
+ file_path: Path,
134
+ show_tokens: bool,
135
+ show_ast: bool,
136
+ config: Optional[Path],
137
+ linter: Optional[Linter] = None,
138
+ return_issues: bool = False,
139
+ silent_errors: bool = False,
140
+ auto_fix: bool = False,
141
+ select: Optional[str] = None,
142
+ ) -> Optional[list]:
143
+ """
144
+ Lint a YAML ProcessObject file (TM1py format).
145
+
146
+ Args:
147
+ file_path: Path to the YAML file
148
+ show_tokens: Whether to display tokens
149
+ show_ast: Whether to display AST
150
+ config: Optional config file path
151
+ linter: Optional pre-created Linter instance (if None, will be created)
152
+ return_issues: If True, return issues instead of reporting and exiting
153
+ silent_errors: If True, return None on error instead of raising Exit
154
+ auto_fix: Whether to automatically fix supported auto-fixable issues
155
+ select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
156
+
157
+ Returns:
158
+ List of (proc_name, issue, yaml_line) tuples if return_issues=True, else None
159
+
160
+ Raises:
161
+ typer.Exit: With appropriate exit code (unless return_issues=True)
162
+ """
163
+ try:
164
+ process = load_yaml_process(file_path)
165
+ except Exception as e:
166
+ if silent_errors:
167
+ return None
168
+ typer.echo(f"Error loading YAML file: {e}", err=True)
169
+ raise typer.Exit(code=1)
170
+
171
+ typer.echo(f"Process: {process.name}")
172
+
173
+ # Use provided linter or create a new one
174
+ if linter is None:
175
+ cfg = load_config(file_path, config)
176
+ token_rules, statement_rules = create_rules(cfg, select=select)
177
+ linter = Linter(rules=token_rules, statement_rules=statement_rules)
178
+
179
+ # Apply auto-fixes if requested
180
+ if auto_fix:
181
+ fixes_by_proc = auto_fix_yaml_procedures(file_path, process, linter)
182
+ if fixes_by_proc:
183
+ total_fixes = sum(fixes_by_proc.values())
184
+ typer.echo(f"Fixed {total_fixes} auto-fixable issue(s) in {file_path}:")
185
+ for proc_name, count in fixes_by_proc.items():
186
+ typer.echo(f" - {proc_name}: {count} fix(es)")
187
+ # Reload process after fixes
188
+ process = load_yaml_process(file_path)
189
+ else:
190
+ typer.echo(f"No auto-fixable issues to fix in {file_path}")
191
+
192
+ # Extract and lint each procedure
193
+ procedures = extract_procedures(process)
194
+ all_issues = []
195
+
196
+ for proc_name, proc_info in procedures.items():
197
+ lint_ctx = LintContext(
198
+ block=proc_name,
199
+ process_name=process.name,
200
+ parameters=process.parameters,
201
+ parameter_lines=process.parameter_lines,
202
+ variables=process.variables,
203
+ variable_lines=process.variable_lines,
204
+ block_start_line=proc_info.source_line,
205
+ block_end_line=proc_info.source_end_line,
206
+ )
207
+ issues = analyze_and_lint_code(
208
+ proc_info.code,
209
+ linter,
210
+ show_tokens,
211
+ show_ast,
212
+ context_label=proc_name,
213
+ lint_context=lint_ctx,
214
+ )
215
+ for issue in issues:
216
+ # Adjust line number to source file location
217
+ adjusted_issue = (proc_name, issue, proc_info.source_line)
218
+ all_issues.append(adjusted_issue)
219
+
220
+ if return_issues:
221
+ return all_issues
222
+ else:
223
+ raise typer.Exit(code=report_yaml_issues(file_path, all_issues))
224
+
225
+
226
+ def lint_directory(
227
+ directory: Path,
228
+ show_tokens: bool,
229
+ show_ast: bool,
230
+ config: Optional[Path],
231
+ auto_fix: bool = False,
232
+ select: Optional[str] = None,
233
+ ) -> None:
234
+ """
235
+ Lint all YAML process files in a directory and all subdirectories.
236
+
237
+ Args:
238
+ directory: Path to the directory
239
+ show_tokens: Whether to display tokens
240
+ show_ast: Whether to display AST
241
+ config: Optional config file path
242
+ auto_fix: Whether to automatically fix supported auto-fixable issues
243
+ select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
244
+
245
+ Raises:
246
+ typer.Exit: With appropriate exit code
247
+ """
248
+ yaml_files = sorted({*directory.rglob("*.yaml"), *directory.rglob("*.yml")})
249
+
250
+ if not yaml_files:
251
+ typer.echo(f"No YAML files found in {directory}", err=False)
252
+ raise typer.Exit(code=0)
253
+
254
+ # Load configuration once for the directory
255
+ cfg = load_config(directory, config)
256
+
257
+ # Lint each YAML file and collect all issues
258
+ all_file_issues = [] # List of (file_path, proc_name, issue, yaml_line) tuples
259
+
260
+ for yaml_file in yaml_files:
261
+ # Create fresh rule instances per file to avoid cross-file state contamination
262
+ token_rules, statement_rules = create_rules(cfg, select=select)
263
+ linter = Linter(rules=token_rules, statement_rules=statement_rules)
264
+
265
+ file_issues = lint_yaml_file(
266
+ yaml_file,
267
+ show_tokens,
268
+ show_ast,
269
+ config,
270
+ linter,
271
+ return_issues=True,
272
+ silent_errors=True,
273
+ auto_fix=auto_fix,
274
+ select=select,
275
+ )
276
+ if file_issues:
277
+ for proc_name, issue, yaml_line in file_issues:
278
+ all_file_issues.append((yaml_file, proc_name, issue, yaml_line))
279
+
280
+ raise typer.Exit(code=report_directory_issues(directory, all_file_issues))