Skip to main content

tfparser_core/loader/
traits.rs

1//! [`Loader`] trait and the default [`HclEditLoader`] implementation.
2
3use std::{
4    io,
5    path::{Path, PathBuf},
6    sync::Arc,
7};
8
9use hcl_edit::parser::parse_body;
10use tracing::instrument;
11
12use super::{LoaderLimits, RawBlock, RawComponent, SourceMap, lowering, source_map::LineIndex};
13use crate::{
14    Diagnostic, LimitKind, Result, Severity,
15    diagnostic::Diagnostic as Diag,
16    discovery::{DirKind, DiscoveredDir},
17    ir::{ComponentKind, FileExt},
18    util::paths,
19};
20
21/// Per-call context handed to [`Loader::load`].
22///
23/// The fields are borrowed — the loader is expected to be called many times
24/// per workspace and the orchestrator owns the source cache and limits.
25#[derive(Debug)]
26#[non_exhaustive]
27pub struct LoadContext<'a> {
28    /// Canonicalised workspace root.
29    pub root: &'a Path,
30    /// Shared `(path → source)` cache. The loader inserts every file it
31    /// reads here so spans render later without re-reading.
32    pub sources: &'a SourceMap,
33    /// Per-file resource caps.
34    pub limits: &'a LoaderLimits,
35}
36
37impl<'a> LoadContext<'a> {
38    /// Construct a context. All fields are borrowed; the caller owns
39    /// lifetime.
40    #[must_use]
41    pub const fn new(root: &'a Path, sources: &'a SourceMap, limits: &'a LoaderLimits) -> Self {
42        Self {
43            root,
44            sources,
45            limits,
46        }
47    }
48}
49
50/// Loader trait. Phase 2 ships a single implementation,
51/// [`HclEditLoader`]; downstream may swap an in-memory variant for
52/// integration tests.
53pub trait Loader: Send + Sync {
54    /// Read every HCL-shaped file in `dir` and produce a [`RawComponent`].
55    ///
56    /// # Errors
57    ///
58    /// Returns [`crate::Error`] for fatal I/O outside per-file scope (e.g. a
59    /// directory disappearing mid-walk). Per-file errors / limit breaches
60    /// surface as [`Diagnostic`]s on the returned [`RawComponent`], and the
61    /// loader continues with the remaining files.
62    fn load(&self, dir: &DiscoveredDir, ctx: &LoadContext<'_>) -> Result<RawComponent>;
63}
64
65/// Default [`Loader`] implementation backed by `hcl-edit::parser::parse_body`.
66///
67/// Stateless and `Send + Sync`. Per the spec it's *pure* w.r.t. external
68/// state — given the same `(dir, sources, limits)`, the output is byte-for-byte
69/// identical (modulo `Arc<str>` identity).
70#[derive(Clone, Copy, Debug, Default)]
71pub struct HclEditLoader;
72
73impl HclEditLoader {
74    /// Construct an [`HclEditLoader`].
75    #[must_use]
76    pub const fn new() -> Self {
77        Self
78    }
79
80    /// Convenience: parse a single byte slice into [`RawBlock`]s without
81    /// touching the filesystem. Used by the fuzz harness and by integration
82    /// tests that synthesise inputs in-memory.
83    #[must_use]
84    pub fn parse_bytes(
85        &self,
86        bytes: &[u8],
87        path: &Arc<Path>,
88        limits: &LoaderLimits,
89    ) -> ParseBytesResult {
90        let mut diagnostics: Vec<Diagnostic> = Vec::new();
91
92        if bytes.len() > limits.max_file_bytes as usize {
93            diagnostics.push(Diag::limit(
94                LimitKind::FileSize,
95                "TF1201",
96                format!(
97                    "file exceeds loader byte cap ({} > {}); skipped",
98                    bytes.len(),
99                    limits.max_file_bytes
100                ),
101            ));
102            return ParseBytesResult {
103                blocks: Vec::new(),
104                diagnostics,
105            };
106        }
107
108        let src = match std::str::from_utf8(bytes) {
109            Ok(s) => s,
110            Err(err) => {
111                diagnostics.push(Diag::new(
112                    Severity::Warn,
113                    "TF1202",
114                    format!("file is not valid UTF-8: {err}"),
115                ));
116                return ParseBytesResult {
117                    blocks: Vec::new(),
118                    diagnostics,
119                };
120            }
121        };
122
123        let body = match parse_body(src) {
124            Ok(b) => b,
125            Err(err) => {
126                diagnostics.push(Diag::new(
127                    Severity::Warn,
128                    "TF1203",
129                    format!("HCL parse error: {err}"),
130                ));
131                return ParseBytesResult {
132                    blocks: Vec::new(),
133                    diagnostics,
134                };
135            }
136        };
137
138        let line_index = LineIndex::build(src);
139        let lowered = lowering::lower_body(&body, path, &line_index, limits, src.len());
140        diagnostics.extend(lowered.diagnostics);
141        ParseBytesResult {
142            blocks: lowered.blocks,
143            diagnostics,
144        }
145    }
146}
147
148/// Output of [`HclEditLoader::parse_bytes`]. Cheap value type used by the
149/// fuzz harness and synthetic-input tests.
150#[derive(Debug, Default)]
151pub struct ParseBytesResult {
152    /// Lowered blocks.
153    pub blocks: Vec<RawBlock>,
154    /// Per-file diagnostics.
155    pub diagnostics: Vec<Diagnostic>,
156}
157
158impl Loader for HclEditLoader {
159    #[instrument(level = "debug", skip(self, ctx), fields(dir = %dir.path.display()))]
160    fn load(&self, dir: &DiscoveredDir, ctx: &LoadContext<'_>) -> Result<RawComponent> {
161        let kind = match dir.kind {
162            DirKind::Component => ComponentKind::Component,
163            DirKind::Module => ComponentKind::Module,
164            DirKind::Environments | DirKind::Files | DirKind::Other => {
165                // The loader is only useful for Component / Module dirs.
166                // Anything else we treat as an empty Module (the orchestrator
167                // generally won't even ask).
168                ComponentKind::Module
169            }
170        };
171        let mut raw = RawComponent::new(Arc::clone(&dir.path), kind);
172
173        for file in &dir.files {
174            if !file.ext.is_hcl() {
175                continue;
176            }
177            // Resolve the absolute path under the root; refuse anything
178            // that escapes (defence in depth — discovery should have done
179            // the same already).
180            let abs = match resolve_under_root(&file.path, ctx.root) {
181                Ok(p) => p,
182                Err(diag) => {
183                    raw.diagnostics.push(diag);
184                    continue;
185                }
186            };
187
188            let bytes = match read_file(&abs, ctx.limits.max_file_bytes) {
189                Ok(b) => b,
190                Err(LoaderReadError::TooLarge { observed, limit }) => {
191                    raw.diagnostics.push(Diag::limit(
192                        LimitKind::FileSize,
193                        "TF1201",
194                        format!(
195                            "file exceeds loader byte cap ({observed} > {limit}); skipped: {}",
196                            file.path.display()
197                        ),
198                    ));
199                    continue;
200                }
201                Err(LoaderReadError::Io { source }) => {
202                    raw.diagnostics.push(Diag::new(
203                        Severity::Warn,
204                        "TF1204",
205                        format!("i/o error reading {}: {source}", file.path.display()),
206                    ));
207                    continue;
208                }
209            };
210
211            let src = match std::str::from_utf8(&bytes) {
212                Ok(s) => Arc::<str>::from(s),
213                Err(err) => {
214                    raw.diagnostics.push(Diag::new(
215                        Severity::Warn,
216                        "TF1202",
217                        format!("file is not valid UTF-8: {} ({err})", file.path.display()),
218                    ));
219                    continue;
220                }
221            };
222
223            ctx.sources.insert(&abs, Arc::clone(&src));
224
225            let body = match parse_body(&src) {
226                Ok(b) => b,
227                Err(err) => {
228                    raw.diagnostics.push(Diag::new(
229                        Severity::Warn,
230                        "TF1203",
231                        format!("HCL parse error in {}: {err}", file.path.display()),
232                    ));
233                    continue;
234                }
235            };
236
237            let line_index = LineIndex::build(&src);
238            let path_arc: Arc<Path> = Arc::clone(&file.path);
239            let lowered =
240                lowering::lower_body(&body, &path_arc, &line_index, ctx.limits, src.len());
241            raw.diagnostics.extend(lowered.diagnostics);
242            for block in lowered.blocks {
243                if !file_ext_supports_block_kind(file.ext, block.kind) {
244                    raw.diagnostics.push(Diag::new(
245                        Severity::Trace,
246                        "TF1205",
247                        format!(
248                            "block `{:?}` in unexpected file extension `{:?}`: {}",
249                            block.kind,
250                            file.ext,
251                            file.path.display()
252                        ),
253                    ));
254                }
255                raw.raw_blocks.push(block);
256            }
257        }
258
259        Ok(raw)
260    }
261}
262
263enum LoaderReadError {
264    TooLarge { observed: u64, limit: u32 },
265    Io { source: io::Error },
266}
267
268fn read_file(path: &Path, max_bytes: u32) -> std::result::Result<Vec<u8>, LoaderReadError> {
269    let metadata = std::fs::metadata(path).map_err(|source| LoaderReadError::Io { source })?;
270    let len = metadata.len();
271    if len > u64::from(max_bytes) {
272        return Err(LoaderReadError::TooLarge {
273            observed: len,
274            limit: max_bytes,
275        });
276    }
277    std::fs::read(path).map_err(|source| LoaderReadError::Io { source })
278}
279
280fn resolve_under_root(rel: &Arc<Path>, root: &Path) -> std::result::Result<PathBuf, Diagnostic> {
281    let candidate = root.join(rel);
282    paths::canonicalize_inside(&candidate, root, paths::SymlinkPolicy::Reject)
283        .map_err(|err| Diag::new(Severity::Warn, "TF1206", format!("path safety: {err}")))
284}
285
286const fn file_ext_supports_block_kind(ext: FileExt, kind: BlockKind) -> bool {
287    use BlockKind as B;
288    match (ext, kind) {
289        (FileExt::Tfvars, B::Unknown) => true,
290        (FileExt::Tf, B::Include | B::Generate | B::Dependency | B::Inputs)
291        | (FileExt::Tfvars | FileExt::Json, _) => false,
292        (FileExt::Tf | FileExt::Hcl | FileExt::TerragruntHcl, _) => true,
293    }
294}
295
296// Block kinds appear in messages so we keep the import alive.
297use crate::ir::BlockKind;
298
299// Phase-1 sanity: trait objects must be Send + Sync.
300#[cfg(test)]
301const _ASSERT_LOADER_IS_OBJECT_SAFE: Option<&dyn Loader> = None;